CTFd plugin that provisions on-demand desktops across a pool of Docker hosts, users click a button and get a browser VNC session with per-container auth and automatic cleanup
When a user requests a session the plugin picks the least-loaded healthy Docker host, creates a container with dynamic port mapping and a random VNC password, and builds a noVNC URL that routes through nginx. Creation runs in a background greenlet so it doesn't block the request thread, and the frontend polls for status updates
All VNC and terminal traffic goes through nginx via auth_request so the Docker hosts don't need to be publicly accessible. nginx makes a subrequest to CTFd to validate the session and get the backend address, then proxies directly to the container. Admins can peek at any user's desktop from the dashboard using the same stored password. Session state lives in the database so active sessions survive CTFd restarts
The workspace UI has three modes selectable from tabs in the bottom bar
- Desktop (noVNC), full graphical desktop in the browser proxied through nginx
- Terminal (ttyd), browser-based shell also proxied through nginx, lower overhead for command-line work
- SSH, direct connection from the user's own terminal with a copyable ssh command and the session password. Requires the mapped SSH port to be reachable from the user's machine
Desktop and Terminal go through the same nginx auth_request flow. SSH is a fallback for native terminal experience
Clone into CTFd's plugin directory and restart CTFd
cd CTFd/CTFd/plugins
git clone <repo-url>Run the setup script from the CTFd root directory, it handles docker-compose volumes, permissions, and nginx config
bash CTFd/plugins/ctfd-remote-desktop/setup.sh
docker compose up -dCTFd runs as a non-root user (uid 1001) inside the container. Get your docker group GID and add the required mounts to docker-compose.yml
stat -c '%g' /var/run/docker.sockservices:
ctfd:
group_add:
- "DOCKER_GID"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ctfd-ssh:/home/ctfd/.ssh:ro
- ~/.docker:/home/ctfd/.docker:ro
depends_on:
permissions:
condition: service_completed_successfully
permissions:
image: alpine:3.23
user: root
volumes:
- ~/.ssh:/mnt/host-ssh:ro
- ctfd-ssh:/mnt/ctfd-ssh
command: >
sh -c '
cp -a /mnt/host-ssh/. /mnt/ctfd-ssh/ &&
chown -R 1001:1001 /mnt/ctfd-ssh
'
volumes:
ctfd-ssh:The socket mount gives local Docker access, the SSH keys are copied into a named volume by the permissions init container with correct ownership (uid 1001 matches ctfd inside the container), and the docker config mount has context metadata. Don't bind-mount ~/.ssh directly, the host UID won't match the container user and paramiko will fail to read known_hosts. If you're only using remote contexts you can skip the socket and group_add
The setup script also handles nginx location blocks for VNC and terminal proxying. For custom nginx configs, see the location blocks in setup.sh and add them to whichever config nginx is actually loading
Single-server deployments need no configuration, on first boot the plugin auto-creates a local context if the Docker socket is reachable
For multi-host setups, create docker contexts and import them from Admin > Config > Remote Desktop
docker context create server1 --docker "host=ssh://user@server1.example.com"
docker context create server2 --docker "host=ssh://user@server2.example.com"The image needs to be pre-pulled on every Docker host. It should expose VNC on 5900, noVNC on 6080, ttyd on 7682, and SSH on 22, and accept CTFD_USERNAME, VNC_PASSWORD, RESOLUTION, and MAX_LIFETIME env vars
All settings live in the database and are managed through Admin > Config > Remote Desktop. On first load everything gets seeded with defaults
| Key | Default | Description |
|---|---|---|
| remote_desktop_enabled | false | master switch for the feature |
| docker_image | ctfd-remote-desktop:latest | container image to run |
| memory_limit | 4g | max memory per container |
| shm_size | 512m | shared memory, needs to fit browser and compositor |
| resolution | 1920x1080 | desktop resolution |
| cpu_limit | 2 | max cpu cores per container |
| initial_duration | 3600 | session length in seconds |
| extension_duration | 1800 | seconds added per extension |
| max_extensions | 3 | max extension count |
| vnc_ready_attempts | 180 | polls waiting for noVNC, 0.5s each |
| cleanup_interval | 300 | seconds between expired session scans |
| pids_limit | 512 | max processes per container |
| max_concurrent_creates | 2 | concurrent creates per host |
| username_source | name | derive container username from CTFd name or email |
| require_verified | true | require email verification, only applies if CTFd has verification enabled |
| command_logging_enabled | false | periodically ingest shell command logs from running containers |
| cap_drop | ALL | linux capabilities to drop |
| cap_add | CHOWN,SETUID,SETGID,FOWNER,DAC_OVERRIDE,NET_RAW,NET_BIND_SERVICE,AUDIT_WRITE,SYS_CHROOT | linux capabilities to add back |
PermissionError(13)on the Docker socket: addgroup_add: ["DOCKER_GID"]to docker-compose where DOCKER_GID is fromstat -c '%g' /var/run/docker.sock- Sessions won't create: check that contexts are configured and the image is pulled on all hosts, use the Test button in the admin UI
- VNC never becomes ready: increase
vnc_ready_attemptsin settings if the container is slow to start - VNC auth fails after recreating a session: browser cached the old password, hard refresh to clear it. Make sure the nginx VNC location has
Cache-Control: no-store - 502 on VNC proxy: check the nginx error log, usually means
resolver 127.0.0.11is missing from the VNC location block or the Docker host isn't resolvable from nginx - A tool won't run with "Operation not permitted": the binary has file capabilities set, run
getcap /path/to/binaryin the container and add the missing caps to Cap Add in settings - Containers piling up on one host: check context weights in the admin UI, a context with weight 2 gets twice the scheduling score
ruff format --check .
ruff check .
mypy .
vulture .
pytest tests/ -v