Build a Self-Hosted Codespace Server

Run isolated VS Code environments in Docker for friends to use privately.

Introduction

30 Second Summary

You want to give friends a place to code on your machine without handing them full access to everything on it. You need isolated environments that each have their own resources, their own files, and no ability to mess with each other.

In this project, you will build a self-hosted codespace server on your Ubuntu machine using Docker containers. Friends SSH in, spin up their own sandboxed VS Code environment in seconds, and code from their browser with hard CPU and memory limits.

What You'll Build

A private development platform where friends connect over a mesh VPN, launch resource-limited workspaces through a restricted SSH gateway, and code in VS Code from their browser.

By the end of this project, you'll have:

  • A custom Docker image with code-server, Node.js, and Python pre-installed, ready to launch as isolated workspaces.
  • A bash workspace manager that creates, lists, connects to, and destroys named workspaces with a single command.
  • A restricted SSH gateway connected via Tailscale so friends can only interact with the workspace manager and nothing else on your machine.
  • Secret Mission: add automatic idle timeout that shuts down workspaces after a configurable period of inactivity.

Are there any prerequisites?

You need an Ubuntu machine (22.04, 24.04, or 26.04), basic terminal comfort (cd, ls, a text editor), and a free Tailscale account (sign up with Gmail or GitHub).

Before We Start

Before We Start...

Install Docker Engine

Your codespace server needs a way to spin up isolated environments for each friend who connects. Docker is the engine that makes this possible.

Each workspace will be a container with its own filesystem, network, and hard resource limits. In this step, you will install Docker Engine on your Ubuntu machine and verify that it can enforce CPU and memory ceilings on containers.

In this step, get ready to:
  • Install Docker Engine from Docker's official apt repository.
  • Configure your user to run Docker without sudo.
  • Verify Docker can enforce CPU and memory limits on containers.
Set up Docker's apt repository and install Docker Engine

Ubuntu's default package repositories include an older, community-maintained Docker package. The official Docker apt repository gives you the latest stable release with all engine features.

  • Update your package index and install prerequisite packages by running this command:
sudo apt update
sudo apt install ca-certificates curl
  • Add Docker's official GPG key and repository by running these commands:
# Create the keyrings directory
sudo install -m 0755 -d /etc/apt/keyrings

# Download Docker's official GPG key
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the Docker repository to your apt sources
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

# Refresh the package index with the new repo
sudo apt update

What do these commands do?

The first block creates a directory for storing repository signing keys, then downloads Docker's GPG key into it. This lets your system verify that packages actually come from Docker.

The second block writes a new apt sources file that points to Docker's package server. Your Ubuntu version codename is inserted automatically so apt fetches the right packages.

  • Install the Docker Engine packages by running:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

What are these packages?

  • docker-ce is the Docker Engine daemon that manages containers.
  • docker-ce-cli is the docker command you type in the terminal.
  • containerd.io is the low-level container runtime that Docker delegates to.
  • docker-buildx-plugin and docker-compose-plugin add image building and multi-container orchestration.
  • Verify the installation by running the hello-world test image:
sudo docker run hello-world
  • You should see a message that says Hello from Docker! followed by an explanation of what just happened.

Not seeing the hello-world message?

  • If you see a permissions error, make sure you included sudo before the command. You will remove the sudo requirement in the next substep.
  • If apt could not find the docker-ce package, run sudo apt update again and check that the repository file exists at /etc/apt/sources.list.d/docker.sources.

Help me troubleshoot my Docker installation

Configure Docker to run without sudo

The Docker daemon communicates through a Unix socket owned by root. Adding your user to the docker group grants access to that socket without needing sudo every time.

  • Create the docker group (it may already exist) by running:
sudo groupadd docker
  • Add your user to the docker group by running:
sudo usermod -aG docker $USER

What does this command do?

The -aG flags mean "append to group." This adds the docker group to your user's list of groups without removing existing ones. $USER is a shell variable that expands to your current username.

  • Log out and log back in so your group membership takes effect.
  • If you are on a virtual machine, you may need to restart it entirely.
  • Alternatively, you can activate the new group in your current session by running:
newgrp docker
  • Verify Docker works without sudo by running:
docker ps
  • You should see an empty table with column headers (CONTAINER ID, IMAGE, COMMAND, etc.) and no error.

Permission denied on docker ps?

  • Make sure you logged out and back in (or ran newgrp docker) after the usermod command.
  • Confirm your user is in the group by running groups and checking that docker appears in the output.

Help me fix Docker permission errors

Verify Docker resource limiting works

Your codespace server will give each friend a container with strict CPU and memory limits. This prevents one workspace from starving the others.

  • Launch a test container with CPU and memory constraints by running:
# Run an Ubuntu container limited to 0.5 CPUs and 256MB of RAM
docker run -d --name limit-test --cpus="0.5" --memory="256m" ubuntu:24.04 sleep 300

What do these flags do?

  • --cpus="0.5" caps the container at half of one CPU core.
  • --memory="256m" sets a hard memory ceiling of 256 megabytes. If the container exceeds this, the kernel terminates processes inside it.
  • sleep 300 keeps the container running for 5 minutes so you can inspect it.
  • Check the resource limits are enforced by running:
docker stats --no-stream
  • You should see a row for limit-test showing MEM USAGE / LIMIT capped at 256MiB and a CPU percentage column.
  • Clean up the test container by running:
docker stop limit-test && docker rm limit-test

Why resource limits matter for your server

By default, a container has no resource constraints and can consume as much CPU and memory as the host allows. When multiple friends are coding at the same time, one runaway process could freeze your entire machine.

The --cpus flag enforces a hard CFS quota at the kernel level. The --memory flag triggers the OOM killer if the container exceeds its ceiling. Together, they guarantee fair sharing.

Seeing 0B / 0B for memory limit?

  • Some Ubuntu installations on certain kernel configurations may not have memory cgroups enabled. Run cat /proc/cgroups and check that the memory row shows 1 in the enabled column.
  • If memory cgroups are disabled, you may need to add cgroup_enable=memory to your kernel boot parameters and reboot.

Help me enable Docker memory limits on my machine

Docker is installed and verified. Next up, you will build a custom Docker image that packages VS Code, Node.js, and Python into a single workspace environment.

Build Your Custom Workspace Image

Docker is installed and ready to go. Now it's time to build the custom environment your friends will actually work inside.

Each workspace needs a consistent development setup with a code editor, Node.js, and Python pre-installed. You'll package all of this into a single Docker image that spins up identical environments every time.

In this step, get ready to:
  • Create a project directory and write a Dockerfile with base dependencies.
  • Add code-server installation and configuration to the image.
  • Build the image and test it by accessing VS Code in your browser.
Create your project directory and base Dockerfile

A Dockerfile is a recipe that tells Docker exactly what to put inside an image. You'll start with Ubuntu as your base and layer on the programming languages your friends need.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir workspace-server
cd workspace-server
  • Confirm the folder exists by running:
ls ~/Desktop

You should see workspace-server in the output.

  • Create and open a new file called Dockerfile by running:
nano Dockerfile

What is a Dockerfile?

A Dockerfile is a plain text file with instructions that Docker reads top-to-bottom to build an image. Each line adds a layer. The result is a reusable template that produces identical containers every time you run it.

  • Paste the following code into the file:
FROM ubuntu:24.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt update && apt install -y \
    curl \
    git \
    python3 \
    python3-pip \
    sudo \
    wget \
    && rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
    && apt install -y nodejs \
    && rm -rf /var/lib/apt/lists/*

What does this code do?

FROM ubuntu:24.04 sets the base operating system for your image. Every workspace starts from a clean Ubuntu install.

ENV DEBIAN_FRONTEND=noninteractive prevents apt from asking interactive questions during the build. Without this, the build would hang waiting for keyboard input.

The first RUN installs essential tools (curl, git, Python 3, sudo, wget). The rm -rf /var/lib/apt/lists/* at the end clears the apt cache to keep the image small.

The second RUN adds the NodeSource repository for Node.js 22 (LTS), then installs it. Your friends get a modern Node.js runtime out of the box.

You won't build the image yet. The next substep completes the Dockerfile with code-server.

Add code-server to the Dockerfile

code-server is an open-source project that runs VS Code in the browser. Each workspace gets its own instance, so your friends can code from any device with just a URL and password.

  • In nano, place your cursor at the end of the file (after the last line) and add a blank line.
  • Paste the following code below the existing content:
RUN curl -fsSL https://code-server.dev/install.sh | sh

RUN useradd -m -s /bin/bash coder \
    && echo "coder ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/coder

USER coder
WORKDIR /home/coder/project

EXPOSE 8080

ENTRYPOINT ["code-server", "--bind-addr", "0.0.0.0:8080", "--auth", "password"]

What does this code do?

The first RUN downloads and runs the official code-server install script. This installs the latest version of code-server system-wide.

The second RUN creates a non-root user called coder with passwordless sudo access. Running as a non-root user is a security best practice for containers.

USER coder switches all subsequent commands (and the container's runtime) to this user. WORKDIR sets the default directory where your friends land when they open a terminal.

ENTRYPOINT tells Docker what to run when the container starts. The flags bind code-server to all network interfaces on port 8080 and enable password authentication via the PASSWORD environment variable.

  • Save the file by pressing Ctrl+O, then press Enter to confirm the filename.
  • Exit nano by pressing Ctrl+X.
  • Verify the file was saved by running:
cat Dockerfile

You should see the full Dockerfile contents printed in your terminal, starting with FROM ubuntu:24.04 and ending with the ENTRYPOINT line.

File looks empty or incomplete?

Make sure you pressed Ctrl+O (the letter O, not zero) and then Enter to confirm the save before exiting. If the content is missing, re-open the file with nano Dockerfile and paste the code again.

help me fix my Dockerfile

✔️ Awesome, I've got everything!

Great. Double check you've saved the file and the full contents appear when you run cat Dockerfile.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile you should have at this point:

FROM ubuntu:24.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt update && apt install -y \
    curl \
    git \
    python3 \
    python3-pip \
    sudo \
    wget \
    && rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
    && apt install -y nodejs \
    && rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://code-server.dev/install.sh | sh

RUN useradd -m -s /bin/bash coder \
    && echo "coder ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/coder

USER coder
WORKDIR /home/coder/project

EXPOSE 8080

ENTRYPOINT ["code-server", "--bind-addr", "0.0.0.0:8080", "--auth", "password"]
Build the image and test it

The Dockerfile is your recipe. Now you'll bake it into an image and launch a container to confirm everything works.

  • Build the Docker image by running this command:
docker build -t workspace-image .

What does this command do?

docker build reads your Dockerfile and executes each instruction to produce an image. The -t workspace-image flag tags (names) the resulting image so you can reference it later. The . at the end tells Docker to look for the Dockerfile in the current directory.

This build takes a few minutes. Docker is downloading Ubuntu, installing packages, and setting up code-server inside the image.

  • Once the build finishes, verify the image exists by running:
docker images

You should see workspace-image listed with a size of roughly 1-2 GB.

Build failed with an error?

Check the error message in your terminal. Common causes include network timeouts (retry the build) and typos in the Dockerfile (run cat Dockerfile and compare against the full code in the tab above).

help me fix my Docker build error

  • Launch a test container from your new image by running:
docker run -d --name test-workspace -p 8080:8080 -e PASSWORD=testpass123 workspace-image

What does this command do?

-d runs the container in the background. --name test-workspace gives it a memorable name so you can stop or remove it easily.

-p 8080:8080 maps port 8080 on your machine to port 8080 inside the container. -e PASSWORD=testpass123 sets the password you'll use to log in to code-server.

  • Open your browser and go to http://localhost:8080.
  • Enter testpass123 as the password and click Submit.

You should see a full VS Code interface running inside the container. This is what your friends will see when they connect to their workspace.

Can't reach localhost:8080?

Run docker ps to check if the container is running. If it exited immediately, run docker logs test-workspace to see what went wrong. A common cause is port 8080 already being in use by another service.

help me access code-server in my browser

  • Stop and remove the test container now that you've confirmed it works:
docker stop test-workspace && docker rm test-workspace

Your custom workspace image is built and tested. Next up, you'll launch resource-limited workspaces from this image and give each one its own persistent storage.

Launch Resource-Limited Workspaces

Your custom image is built and ready to go. Now it's time to actually launch workspaces from it and control how much CPU and memory each one can consume.

Without resource limits, a single container could hog all of your machine's resources and starve everything else. Docker lets you put hard ceilings on each container so one runaway workspace can never bring down the whole system.

In this step, get ready to:
  • Launch a container with CPU and memory limits, then verify those limits are enforced.
  • Add persistent storage using Docker volumes so files survive restarts.
  • Run two workspaces side by side on separate ports with different resource allocations.
Run a workspace with resource constraints

By default, a container has no resource constraints and can use as much of a given resource as the host's kernel scheduler allows. That means a single workspace could eat all 16 GB of your RAM if something goes wrong inside it.

  • Launch a test workspace with a 2 GB memory cap and 1 CPU core by running this command:
docker run -d \
    --name ws-test \
    --cpus 1 \
    --memory 2g \
    --memory-swap 2g \
    -p 8081:8080 \
    -e PASSWORD=testpass123 \
    workspace-image

What do these flags do?

  • -d runs the container in detached mode (in the background).
  • --name ws-test gives the container a human-readable name you can reference later.
  • --cpus 1 sets a hard CFS quota limiting this container to one CPU core maximum.
  • --memory 2g caps memory at 2 GB. If a process tries to exceed this, the kernel terminates it.
  • --memory-swap 2g set equal to --memory disables swap for the container, making the memory cap absolute.
  • -e PASSWORD=testpass123 sets the code-server login password via an environment variable.
  • Verify the resource limits are applied by running:
docker stats ws-test --no-stream

You should see a table with columns including MEM USAGE / LIMIT showing your 2 GB cap, and CPU % showing usage constrained to one core.

  • Test that the memory limit is real by running a Python command inside the container that tries to allocate 3 GB:
docker exec ws-test python3 -c "x = bytearray(3 * 1024**3)"

You should see the process get terminated with a Killed message. The kernel enforces the 2 GB ceiling and stops any process that tries to exceed it.

What happens when a container exceeds its memory limit?

The Linux kernel sends a SIGKILL signal to the offending process. The process is terminated immediately with exit code 137 (128 + 9 for SIGKILL). The container itself keeps running because only the specific process that exceeded the limit is killed, not the main code-server process.

  • Clean up this test container since you will create the real workspaces next:
docker stop ws-test && docker rm ws-test

You should see ws-test printed twice, confirming the container was stopped and removed.

Container won't start?

  • If you see a port conflict error, another process is already using port 8081. Run docker ps to check for leftover containers from the previous step, and stop them with docker stop <name>.
  • If you see an image-not-found error, make sure you built the image with the exact tag workspace-image in the previous step. Run docker images to verify.

Help me debug why my workspace container won't start

Add persistent storage with Docker volumes

Right now, any files created inside a container vanish the moment you remove it. Docker volumes give each workspace its own persistent storage area that survives container stops and restarts.

  • Launch the first real workspace with a named volume by running:
docker run -d \
    --name ws-alice \
    --cpus 1 \
    --memory 2g \
    --memory-swap 2g \
    -p 8081:8080 \
    -v ws-alice-data:/home/coder/project \
    -e PASSWORD=alice123 \
    --restart unless-stopped \
    workspace-image

What does the volume flag do?

-v ws-alice-data:/home/coder/project creates a named volume called ws-alice-data and mounts it at the /home/coder/project path inside the container. Everything the user saves in their VS Code workspace lands on this volume.

--restart unless-stopped tells Docker to automatically restart this container if it crashes or the machine reboots. This keeps workspaces available without manual intervention.

  • Verify the volume persists across restarts. First, create a test file inside the container:
docker exec ws-alice bash -c "echo 'hello from alice' > /home/coder/project/test.txt"
  • Stop and restart the container to prove the file survives:
docker stop ws-alice
docker start ws-alice
  • Check that the file still exists:
docker exec ws-alice cat /home/coder/project/test.txt

You should see hello from alice printed back. The file survived because it lives on the ws-alice-data volume, not inside the disposable container filesystem.

File not found after restart?

  • Make sure you wrote the file to /home/coder/project/ and not another directory. Only paths mounted to the volume persist.
  • Run docker volume ls to confirm ws-alice-data exists. If it does not, the -v flag was not included in your docker run command.

Help me figure out why my volume mount is not working

Run two workspaces side by side

The real power of this system is running multiple isolated workspaces simultaneously. Each one gets its own port, its own volume, and its own resource allocation.

  • Launch a second workspace with a smaller resource allocation by running:
docker run -d \
    --name ws-bob \
    --cpus 0.5 \
    --memory 1g \
    --memory-swap 1g \
    -p 8082:8080 \
    -v ws-bob-data:/home/coder/project \
    -e PASSWORD=bob456 \
    --restart unless-stopped \
    workspace-image

Why different resource allocations?

Alice gets 2 GB of RAM and 1 full CPU core. Bob gets 1 GB and half a core. This mirrors a real scenario where you might give frequent users more resources and casual users less. Each container's limits are independently enforced by the kernel.

  • Verify both workspaces are running with their respective limits by running:
docker stats --no-stream

You should see both ws-alice and ws-bob listed. The MEM USAGE / LIMIT column shows 2 GiB for Alice and 1 GiB for Bob.

  • Open your browser and navigate to http://localhost:8081 to access Alice's workspace.
  • Enter alice123 as the password.
  • Open a second browser tab and navigate to http://localhost:8082 to access Bob's workspace.
  • Enter bob456 as the password.

You should see a full VS Code interface in each tab, running completely independently of each other.

Can't access one of the workspaces in the browser?

  • Run docker ps to confirm both containers show a status of Up. If one is missing, it may have failed to start due to a port conflict.
  • Make sure you are using the correct port for each workspace. Alice is on 8081 and Bob is on 8082.
  • If code-server shows a loading spinner that never resolves, give it 10-15 seconds. The first load takes longer as code-server initializes.

Help me troubleshoot why my workspace is not loading in the browser

You now have two fully isolated workspaces running side by side, each with hard resource ceilings and persistent storage. Next up, you will write a bash script that automates all of this into simple commands like create, list, and destroy.

Write the Workspace Manager Script

In the previous step, you launched workspaces manually by typing long docker run commands with multiple flags.

That approach works, but it is error-prone and tedious to repeat for every new workspace. In this step, you will wrap all of that logic into a single bash script called workspace.sh. One command will handle creating, listing, stopping, destroying, and connecting to workspaces.

In this step, get ready to:
  • Create workspace.sh with a create command that launches named workspaces.
  • Add list, stop, and destroy commands for managing workspaces.
  • Add a connect command for dropping into a workspace shell.
Create workspace.sh with the create command

The script needs infrastructure before it can do anything useful. Configuration variables define the image name and base port, a usage function prints help text, and two small helper functions handle port allocation and password generation.

  • Clean up any leftover test containers from the previous step by running these commands:
docker stop ws-alice ws-bob 2>/dev/null
docker rm ws-alice ws-bob 2>/dev/null
  • Create workspace.sh inside your ~/workspace-server directory and add the file header, configuration, usage function, and helper functions by pasting this code:
#!/bin/bash

IMAGE="workspace-image"
BASE_PORT=8080
WORKSPACE_DIR="/opt/workspaces"

usage() {
    echo "Usage: workspace.sh {create|list|stop|destroy|connect} [options]"
    echo ""
    echo "Commands:"
    echo "  create <name> <memory> <cpus>  - Create a new workspace"
    echo "  list                           - List all workspaces"
    echo "  stop <name>                    - Stop a workspace"
    echo "  destroy <name>                 - Remove a workspace completely"
    echo "  connect <name>                 - Open a shell in a workspace"
    echo ""
    echo "Examples:"
    echo "  workspace.sh create bob 2g 1"
    echo "  workspace.sh list"
    echo "  workspace.sh connect bob"
    echo "  workspace.sh destroy bob"
}

get_next_port() {
    local port=$((BASE_PORT + 1))
    while docker ps -a --format '{{.Ports}}' | grep -q ":${port}->"; do
        port=$((port + 1))
    done
    echo "$port"
}

generate_password() {
    openssl rand -hex 8
}

What does this code do?

  • IMAGE and BASE_PORT define which Docker image to launch and the starting port number for workspaces.
  • usage() prints help text when the script is run without arguments or with an invalid command.
  • get_next_port() scans existing containers and finds the first available port above 8080.
  • generate_password() uses openssl rand -hex 8 to create a random 16-character hex string for code-server authentication.

You won't see a change yet. The next chunk adds the function that actually creates workspaces.

  • Below the generate_password function, add the cmd_create function by pasting this code:
cmd_create() {
    local name="$1"
    local memory="${2:-2g}"
    local cpus="${3:-1}"

    if [ -z "$name" ]; then
        echo "Error: workspace name required"
        echo "Usage: workspace.sh create <name> <memory> <cpus>"
        return 1
    fi

    if docker ps -a --format '{{.Names}}' | grep -q "^ws-${name}$"; then
        echo "Error: workspace 'ws-${name}' already exists"
        return 1
    fi

    local port
    port=$(get_next_port)
    local password
    password=$(generate_password)

    docker run -d \
        --name "ws-${name}" \
        --cpus "$cpus" \
        --memory "$memory" \
        --memory-swap "$memory" \
        -p "${port}:8080" \
        -v "ws-${name}-data:/home/coder/project" \
        -e "PASSWORD=${password}" \
        --restart unless-stopped \
        "$IMAGE"

    if [ $? -eq 0 ]; then
        echo "Workspace created successfully!"
        echo "  Name:     $name"
        echo "  URL:      http://$(hostname -I | awk '{print $1}'):${port}"
        echo "  Password: $password"
        echo "  Memory:   $memory"
        echo "  CPUs:     $cpus"
    else
        echo "Error: failed to create workspace"
        return 1
    fi
}

What does this code do?

  • The function accepts a name, memory limit (default 2g), and CPU count (default 1).
  • It checks for a missing name and for duplicate containers before doing anything destructive.
  • The docker run call uses the same flags you typed manually in the previous step: --cpus, --memory, a named volume, and the PASSWORD environment variable for code-server.
  • On success it prints the workspace URL, password, and resource limits so the user knows how to connect.

You won't see a change yet. The next chunk adds the command dispatcher that routes user input to the right function.

  • At the very bottom of workspace.sh, add the case statement that routes commands by pasting this code:
case "${1}" in
    create)  cmd_create "$2" "$3" "$4" ;;
    list)    cmd_list ;;
    stop)    cmd_stop "$2" ;;
    destroy) cmd_destroy "$2" ;;
    connect) cmd_connect "$2" ;;
    *)       usage ;;
esac

What does this code do?

The case statement reads the first argument passed to the script and calls the matching function. If no argument matches, it falls back to usage().

The list, stop, destroy, and connect functions don't exist yet. The script will only error if you try to call one of those commands before adding the functions in the next substeps.

  • Save workspace.sh.
  • Make the script executable by running this command:
chmod +x workspace.sh
  • Test the create command by running:
./workspace.sh create bob 2g 1

You should see output showing the workspace name, a URL with a port number, a generated password, and the resource limits.

Seeing an error about the workspace already existing?

If you see Error: workspace 'ws-bob' already exists, the old test container from the previous step was not removed. Run docker rm ws-bob and try again.

If you see a permission error on openssl, make sure the script has execute permissions with chmod +x workspace.sh.

Help me troubleshoot my create command.

Add list, stop, and destroy commands

A workspace manager that can only create workspaces is not very useful. You need to see what is running, gracefully shut things down, and clean up when a workspace is no longer needed.

  • In workspace.sh, add the cmd_list function above the case statement by pasting this code:
cmd_list() {
    echo "Running workspaces:"
    echo "-------------------"
    docker ps --filter "name=ws-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null

    echo ""
    echo "Stopped workspaces:"
    echo "-------------------"
    docker ps -a --filter "name=ws-" --filter "status=exited" --format "table {{.Names}}\t{{.Status}}" 2>/dev/null
}

What does this code do?

The function uses docker ps with a name filter to show only workspace containers (those prefixed with ws-). It lists running workspaces first, then stopped ones separately so you can see which need attention.

  • Save workspace.sh and verify by running:
./workspace.sh list

You should see ws-bob listed under running workspaces with its port mapping.

  • Below cmd_list, add the cmd_stop function by pasting this code:
cmd_stop() {
    local name="$1"
    if [ -z "$name" ]; then
        echo "Error: workspace name required"
        return 1
    fi

    if [ "$name" = "--all" ]; then
        docker stop $(docker ps --filter "name=ws-" -q) 2>/dev/null
        echo "All workspaces stopped."
        return 0
    fi

    docker stop "ws-${name}" 2>/dev/null
    if [ $? -eq 0 ]; then
        echo "Workspace '$name' stopped."
    else
        echo "Error: workspace '$name' not found or not running."
        return 1
    fi
}

What does this code do?

The function gracefully stops a single workspace by name. It also supports a --all flag that stops every running workspace container at once. The container and its volume are preserved so the workspace can be restarted later.

  • Below cmd_stop, add the cmd_destroy function by pasting this code:
cmd_destroy() {
    local name="$1"
    if [ -z "$name" ]; then
        echo "Error: workspace name required"
        return 1
    fi

    docker stop "ws-${name}" 2>/dev/null
    docker rm "ws-${name}" 2>/dev/null
    docker volume rm "ws-${name}-data" 2>/dev/null

    echo "Workspace '$name' destroyed (container and data removed)."
}

What does this code do?

Unlike stop, destroy removes everything. It stops the container, removes it, and deletes the named volume. All files the user saved inside that workspace are gone permanently.

  • Save workspace.sh and test the stop and destroy commands by running:
./workspace.sh stop bob
./workspace.sh destroy bob

You should see confirmation messages for each action. Run ./workspace.sh list again to verify that ws-bob no longer appears.

Stop command says workspace not found?

If bob was already stopped from an earlier test, docker stop will fail. That is expected. The destroy command still works because it suppresses errors with 2>/dev/null and proceeds to remove the container and volume regardless.

Help me debug my stop or destroy command.

Add a connect command

Sometimes you need a terminal inside a running workspace instead of the browser-based editor. The connect command drops you into a bash shell inside the container using docker exec.

  • In workspace.sh, add the cmd_connect function above the case statement by pasting this code:
cmd_connect() {
    local name="$1"
    if [ -z "$name" ]; then
        echo "Error: workspace name required"
        return 1
    fi

    if ! docker ps --format '{{.Names}}' | grep -q "^ws-${name}$"; then
        echo "Error: workspace '$name' is not running."
        return 1
    fi

    echo "Connecting to workspace '$name'... (type 'exit' to disconnect)"
    docker exec -it "ws-${name}" /bin/bash
}

What does this code do?

The function first checks that the workspace is actually running. If it is, docker exec -it opens an interactive bash session inside the container. Typing exit returns you to your host machine without stopping the workspace.

  • Save workspace.sh.
  • Create a fresh workspace to test the full flow by running:
./workspace.sh create bob 2g 1
  • Connect to the workspace by running:
./workspace.sh connect bob

You should land inside the container with a bash prompt. Your username shows as coder and the working directory is /home/coder/project.

  • Type exit to return to your host machine.

Connect says workspace is not running?

Make sure you ran the create command successfully before trying connect. Run ./workspace.sh list to confirm ws-bob appears under running workspaces.

Help me debug why connect says my workspace is not running.

✔️ Awesome, I've got everything!

Great. Double check you have saved workspace.sh and that all five commands work.

ⓧ I'd like to double check the full code

Here is the complete workspace.sh for comparison:

#!/bin/bash

IMAGE="workspace-image"
BASE_PORT=8080
WORKSPACE_DIR="/opt/workspaces"

usage() {
    echo "Usage: workspace.sh {create|list|stop|destroy|connect} [options]"
    echo ""
    echo "Commands:"
    echo "  create <name> <memory> <cpus>  - Create a new workspace"
    echo "  list                           - List all workspaces"
    echo "  stop <name>                    - Stop a workspace"
    echo "  destroy <name>                 - Remove a workspace completely"
    echo "  connect <name>                 - Open a shell in a workspace"
    echo ""
    echo "Examples:"
    echo "  workspace.sh create bob 2g 1"
    echo "  workspace.sh list"
    echo "  workspace.sh connect bob"
    echo "  workspace.sh destroy bob"
}

get_next_port() {
    local port=$((BASE_PORT + 1))
    while docker ps -a --format '{{.Ports}}' | grep -q ":${port}->"; do
        port=$((port + 1))
    done
    echo "$port"
}

generate_password() {
    openssl rand -hex 8
}

cmd_create() {
    local name="$1"
    local memory="${2:-2g}"
    local cpus="${3:-1}"

    if [ -z "$name" ]; then
        echo "Error: workspace name required"
        echo "Usage: workspace.sh create <name> <memory> <cpus>"
        return 1
    fi

    if docker ps -a --format '{{.Names}}' | grep -q "^ws-${name}$"; then
        echo "Error: workspace 'ws-${name}' already exists"
        return 1
    fi

    local port
    port=$(get_next_port)
    local password
    password=$(generate_password)

    docker run -d \
        --name "ws-${name}" \
        --cpus "$cpus" \
        --memory "$memory" \
        --memory-swap "$memory" \
        -p "${port}:8080" \
        -v "ws-${name}-data:/home/coder/project" \
        -e "PASSWORD=${password}" \
        --restart unless-stopped \
        "$IMAGE"

    if [ $? -eq 0 ]; then
        echo "Workspace created successfully!"
        echo "  Name:     $name"
        echo "  URL:      http://$(hostname -I | awk '{print $1}'):${port}"
        echo "  Password: $password"
        echo "  Memory:   $memory"
        echo "  CPUs:     $cpus"
    else
        echo "Error: failed to create workspace"
        return 1
    fi
}

cmd_list() {
    echo "Running workspaces:"
    echo "-------------------"
    docker ps --filter "name=ws-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null

    echo ""
    echo "Stopped workspaces:"
    echo "-------------------"
    docker ps -a --filter "name=ws-" --filter "status=exited" --format "table {{.Names}}\t{{.Status}}" 2>/dev/null
}

cmd_stop() {
    local name="$1"
    if [ -z "$name" ]; then
        echo "Error: workspace name required"
        return 1
    fi

    if [ "$name" = "--all" ]; then
        docker stop $(docker ps --filter "name=ws-" -q) 2>/dev/null
        echo "All workspaces stopped."
        return 0
    fi

    docker stop "ws-${name}" 2>/dev/null
    if [ $? -eq 0 ]; then
        echo "Workspace '$name' stopped."
    else
        echo "Error: workspace '$name' not found or not running."
        return 1
    fi
}

cmd_destroy() {
    local name="$1"
    if [ -z "$name" ]; then
        echo "Error: workspace name required"
        return 1
    fi

    docker stop "ws-${name}" 2>/dev/null
    docker rm "ws-${name}" 2>/dev/null
    docker volume rm "ws-${name}-data" 2>/dev/null

    echo "Workspace '$name' destroyed (container and data removed)."
}

cmd_connect() {
    local name="$1"
    if [ -z "$name" ]; then
        echo "Error: workspace name required"
        return 1
    fi

    if ! docker ps --format '{{.Names}}' | grep -q "^ws-${name}$"; then
        echo "Error: workspace '$name' is not running."
        return 1
    fi

    echo "Connecting to workspace '$name'... (type 'exit' to disconnect)"
    docker exec -it "ws-${name}" /bin/bash
}

case "${1}" in
    create)  cmd_create "$2" "$3" "$4" ;;
    list)    cmd_list ;;
    stop)    cmd_stop "$2" ;;
    destroy) cmd_destroy "$2" ;;
    connect) cmd_connect "$2" ;;
    *)       usage ;;
esac

Your workspace manager script is complete. You can now spin up isolated development environments with a single command. Next up, you will lock down SSH access so friends can use this script remotely without touching anything else on your machine.

Set Up Restricted SSH Access

Your workspace manager script works perfectly from your own terminal. But the whole point of this project is to let friends use it too.

In this step, you will create a restricted gateway that lets friends SSH into your machine and interact with the workspace manager without being able to access anything else. You will use Tailscale to provide private network access, a dedicated Linux user account, and SSH's ForceCommand directive to lock them into the menu script.

In this step, get ready to:
  • Create a restricted Linux user and install Tailscale for private network access.
  • Configure SSH ForceCommand to lock the user into the workspace manager menu.
  • Test the full flow from SSH login to browser-based VS Code.
Create a workspace user, install Tailscale, and connect to your tailnet

Friends need a user account to SSH into. A dedicated user with no special privileges keeps them isolated from your personal account and system files.

  • Create a new Linux user called workspace and set a password for it by running these commands:
sudo useradd -m -s /bin/bash workspace
sudo passwd workspace

What do these commands do?

  • useradd -m -s /bin/bash workspace creates a new user with a home directory and bash as their shell.
  • passwd workspace prompts you to set a password. This is what friends will use to SSH in.
  • When prompted, type a password you will share with friends and confirm it.

Next, you need a way for friends to reach your machine privately. Tailscale creates a mesh VPN between devices so you never have to expose ports to the public internet.

  • Install Tailscale by running this command:
curl -fsSL https://tailscale.com/install.sh | sh
  • Connect your machine to your tailnet by running:
sudo tailscale up

What happens when you run tailscale up?

Tailscale prints a URL in the terminal. Open that URL in your browser to authenticate your machine with your Tailscale account. Once authenticated, your machine joins your private tailnet and gets a Tailscale IP address that only devices on your tailnet can reach.

  • Open the URL printed in the terminal in your browser.
  • Log in with your Tailscale account (Gmail or GitHub).
  • After authenticating, note your machine's Tailscale IP by running:
tailscale ip
  • You should see an IP address starting with 100. printed in the terminal. Write this down. Friends will use this IP to connect.

Why Tailscale instead of port forwarding?

Port forwarding exposes your machine to the entire internet, requiring firewall rules, dynamic DNS, and SSL certificates. Tailscale creates a private mesh network using WireGuard encryption. Friends install Tailscale, join your tailnet, and reach your server at its Tailscale IP. No DNS, no firewall holes, no public exposure.

Now you need to copy your workspace scripts to a system-wide location so the restricted user can access them.

  • Create the system directory and copy both scripts by running:
sudo mkdir -p /opt/workspace-server
sudo cp ~/workspace-server/workspace.sh /opt/workspace-server/workspace.sh
sudo chmod +x /opt/workspace-server/workspace.sh

Why copy to /opt/workspace-server?

The /opt directory is the standard location for self-contained application software on Linux. Placing the scripts here makes them accessible system-wide without relying on any specific user's home directory.

Next, you need to create the menu script that friends will interact with when they SSH in.

  • Create the workspace menu script by running:
sudo nano /opt/workspace-server/workspace-menu.sh
  • Paste the following code into the file:
#!/bin/bash

SCRIPT_DIR="/opt/workspace-server"

echo ""
echo "================================"
echo "  Workspace Manager"
echo "================================"
echo ""

while true; do
    echo "Commands:"
    echo "  create <name> <memory> <cpus>  - Spin up a workspace"
    echo "  list                           - Show all workspaces"
    echo "  stop <name>                    - Stop a workspace"
    echo "  destroy <name>                 - Remove a workspace"
    echo "  connect <name>                 - SSH into your workspace"
    echo "  exit                           - Disconnect"
    echo ""
    read -rp "> " input

    if [ -z "$input" ]; then
        continue
    fi

    read -ra args <<< "$input"
    cmd="${args[0]}"

    case "$cmd" in
        create|list|stop|destroy|connect)
            "${SCRIPT_DIR}/workspace.sh" "${args[@]}"
            echo ""
            ;;
        exit|quit)
            echo "Goodbye!"
            exit 0
            ;;
        *)
            echo "Unknown command: $cmd"
            echo ""
            ;;
    esac
done
  • Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
  • Make it executable by running:
sudo chmod +x /opt/workspace-server/workspace-menu.sh

What does workspace-menu.sh do?

This script displays a simple command prompt where friends can type workspace commands. It wraps your workspace.sh script with a user-friendly menu loop. The exit command cleanly disconnects the SSH session.

The workspace user also needs permission to run Docker commands.

  • Add the workspace user to the docker group by running:
sudo usermod -aG docker workspace
  • Verify your scripts are in place by running:
ls /opt/workspace-server/
  • You should see workspace.sh and workspace-menu.sh listed in the output.

Tailscale not connecting?

  • Make sure you opened the authentication URL in a browser and logged in. The terminal will hang until you authenticate.
  • If you see a permissions error, ensure you ran sudo tailscale up with sudo.
  • If you do not have a Tailscale account yet, sign up for free at tailscale.com using your Gmail or GitHub account.

Why can't I connect to Tailscale or see my IP?

Configure SSH ForceCommand to restrict the workspace user

SSH's ForceCommand directive forces a specific command to run when a user logs in, ignoring whatever command the SSH client requested. Combined with disabled forwarding, this creates a locked-down gateway.

  • Open the SSH configuration file by running:
sudo nano /etc/ssh/sshd_config
  • Scroll to the very bottom of the file.
  • Add the following lines at the end:
Match User workspace
    ForceCommand /opt/workspace-server/workspace-menu.sh
    AllowAgentForwarding no
    AllowTcpForwarding no
    X11Forwarding no
  • Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.

What does this configuration do?

  • Match User workspace applies the following rules only to the workspace user. Other users are unaffected.
  • ForceCommand overrides whatever command the client tries to run and launches your menu script instead.
  • AllowAgentForwarding no prevents SSH agent forwarding through this session.
  • AllowTcpForwarding no blocks port tunneling, so friends cannot tunnel traffic through your machine.
  • X11Forwarding no disables graphical application forwarding.
  • Restart the SSH service to apply the changes by running:
sudo systemctl restart ssh
  • You should see no output if the restart succeeds.

SSH service failed to restart?

  • Check for syntax errors in your config by running sudo sshd -t before restarting. It will print the line number of any error.
  • Make sure the Match User workspace block is at the very end of the file. Match blocks must come after all global settings.
  • Verify the path /opt/workspace-server/workspace-menu.sh exists and is executable.

Help me fix my sshd_config so the SSH service restarts cleanly.

Test the full SSH-to-workspace flow

Everything is wired up. Time to test the complete experience from your friend's perspective.

  • SSH into your machine as the workspace user over your Tailscale IP by running:
ssh workspace@$(tailscale ip -4)
  • Enter the password you set earlier when prompted.
  • You should immediately see the Workspace Manager banner and the command prompt.

What should I see?

================================
  Workspace Manager
================================

Commands:
  create <name> <memory> <cpus>  - Spin up a workspace
  list                           - Show all workspaces
  stop <name>                    - Stop a workspace
  destroy <name>                 - Remove a workspace
  connect <name>                 - SSH into your workspace
  exit                           - Disconnect

> 

If you see this menu, the ForceCommand is working. There is no way to access a regular shell.

  • Create a test workspace from within the menu by typing:
create testuser 2g 1
  • You should see output showing the workspace name, URL, password, and resource limits.
  • Open the URL shown in the output in your browser. It will be http:// followed by your Tailscale IP and a port number.
  • Enter the generated password when code-server prompts for it.
  • You should see the full VS Code interface running in your browser.
  • Back in the SSH session, destroy the test workspace by typing:
destroy testuser
  • Type exit to disconnect from the SSH session.

Dropped into a regular shell instead of the menu?

  • Make sure you restarted SSH with sudo systemctl restart ssh after editing the config.
  • Verify the Match block targets workspace (the exact username you created).
  • Check that /opt/workspace-server/workspace-menu.sh has the execute bit set. Run ls -la /opt/workspace-server/workspace-menu.sh and look for x in the permissions.

Why am I getting a regular shell instead of the workspace menu when SSHing?

Your codespace server is fully operational. Friends can SSH in over Tailscale, spin up isolated development environments, and code in VS Code from their browser. The ForceCommand lockdown means they cannot escape the workspace menu or access your system. Next up, you can take this further with the Secret Mission.

Secret mission

Add Idle Timeout and Auto-Cleanup

What happens when a friend leaves their workspace running overnight? Right now, nothing stops it from consuming resources forever. In this bonus mission, you'll add an automatic idle timeout that detects inactive workspaces and shuts them down after a configurable period.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with free tools, so there are no ongoing financial costs. Running workspaces do consume CPU, memory, and disk space on your machine.

Resources you used:

  • Docker containers (any workspaces you created).
  • Docker volumes for persistent workspace storage (uses disk space).
  • Docker image workspace-image (built in Step 2).
  • Project directory at /opt/workspace-server containing your scripts.
  • A workspace Linux user on your machine.
  • SSH configuration changes in /etc/ssh/sshd_config (Match User block).
  • A cron job or systemd timer running cleanup.sh every 5 minutes.
  • Tailscale connection (free tier, connected to your tailnet).
  • Docker Engine (installed in Step 1).

Keep everything running

No action needed. Choose this if you are still actively building or sharing workspaces with friends.

  • Docker and Tailscale stay active in the background.
  • Workspaces only consume CPU and memory while running. Stopped workspaces use only disk space for their volumes.
  • The cleanup cron job continues running every 5 minutes, automatically stopping idle workspaces based on their timeout labels.

Pause - I'll come back to this later

Shut down all running workspaces to free up CPU and memory. Everything stays installed so you can restart later.

  • Stop all running workspaces by running:
  • Docker volumes, the custom image, and all scripts remain in place.
  • Tailscale stays connected but no ports are actively serving.
  • To resume later, start a workspace with ./workspace.sh create <name> <memory> <cpus>.
./workspace.sh stop --all

Your workspace data is preserved in Docker volumes and will be reattached when you create new workspaces with the same names.

Delete - I don't want to use this again

Remove all project resources completely and start fresh. This is permanent.

  • Stop and remove all workspace containers by running:
docker stop $(docker ps --filter "name=ws-" -q) 2>/dev/null
docker rm $(docker ps -a --filter "name=ws-" -q) 2>/dev/null
  • Remove all workspace volumes (this deletes all saved workspace files) by running:
docker volume rm $(docker volume ls --filter "name=ws-" -q) 2>/dev/null
  • Remove the custom Docker image by running:
docker rmi workspace-image
  • Remove the cron job by running:
sudo crontab -l | grep -v 'cleanup.sh' | sudo crontab -
  • Remove the project directory by running:
sudo rm -rf /opt/workspace-server
  • Remove the SSH Match User block. Open the SSH config file by running:
sudo nano /etc/ssh/sshd_config

Find and delete the entire Match User workspace block (including the ForceCommand, AllowAgentForwarding, AllowTcpForwarding, and X11Forwarding lines). Save and exit, then restart SSH.

sudo systemctl restart sshd
  • Remove the workspace Linux user and their home directory by running:
sudo userdel -r workspace
  • Uninstall Tailscale if you no longer need it by running:
sudo apt purge tailscale -y
sudo rm -rf /var/lib/tailscale
  • Uninstall Docker Engine if you no longer need it by running:
sudo apt purge docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
sudo rm -rf /var/lib/docker /var/lib/containerd

Your machine is now back to its original state with all project resources removed.

Nice Work!

Nice Work!

You just built a private, self-hosted development platform from scratch. Friends can SSH in, spin up their own isolated coding environment, and write code in VS Code from their browser.

You learned how to:

  • Build a custom Docker image with code-server, Node.js, and Python baked in, then launch resource-limited workspaces with hard CPU and memory caps.
  • Write a bash workspace manager that creates, lists, connects to, stops, and destroys named workspaces with a single command.
  • Configure restricted SSH access using ForceCommand so friends can only interact with the workspace manager, and connect everything privately via Tailscale mesh VPN.
  • Secret Mission: Added automatic idle timeout that monitors workspace CPU usage and auto-stops abandoned containers after a configurable period.

Ready to quiz yourself?