Ubuntu 24.04 XFCE desktop in a container, accessible over VNC, xrdp,
and SSH. Runs systemd as PID 1 so services start, stop, and restart
cleanly. Headless by default; opt in to NVIDIA GPU passthrough when you need
it.
| Tag | Size | Includes |
|---|---|---|
:latest |
full | XFCE + VNC + xrdp + SSH + dev tools + OBS, VLC, Firefox, VS Code |
:slim |
small | XFCE + VNC + xrdp + SSH + dev tools (no media / browser / IDE) |
"Dev tools" = build-essential, g++, plus Python 3.10, 3.11, 3.12, 3.13
(distro default 3.12 + deadsnakes 3.10 / 3.11 / 3.13). Each is callable as
python3.10, python3.11, etc., with matching -venv and -dev packages.
Override at build time via --build-arg PYTHON_VERSIONS="3.11 3.13" or set
to empty to ship only the distro default.
NoMachine NX server (port 4000) is an opt-in build-time component on either
variant; enable with INCLUDE_NOMACHINE=true at build time and run the
container with --cap-add=SYS_PTRACE. See Building locally.
Both are published for linux/amd64 and linux/arm64 to:
ghcr.io/mscrnt/ubuntu-desktopdocker.io/mscrnt/ubuntu-desktop
Images are signed with cosign (keyless OIDC) and ship with SBOM + provenance attestations.
docker run -d --name desktop \
--cgroupns=host \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
--tmpfs /run --tmpfs /run/lock --tmpfs /tmp \
-p 2222:22 -p 13389:3389 -p 5901:5901 \
-e USERNAME=user \
-e PASSWORD=change-me \
-e VNCPASSWORD=change-me \
ghcr.io/mscrnt/ubuntu-desktop:latestsystemd needs a writable cgroup tree. On cgroup v2 hosts the bind mount above (with
--cgroupns=host) is sufficient — no--privilegedflag and no extra capabilities are required.
Host port
13389is used for RDP because Windows reserves3389for its own Remote Desktop service even when it isn't actively serving connections. Mapping to3389on a Windows host causesmstscto refuse the connection with error0x708/ "console session in progress". Linux hosts can use-p 3389:3389freely.
Connect:
| Protocol | Host port | Notes |
|---|---|---|
| RDP | 13389 |
Windows hosts: avoid 3389 (Windows RDP reserves it). Use mstsc, FreeRDP, NoMachine-as-RDP, etc. |
| VNC | 5901 |
Any VNC client; uses VNCPASSWORD. Use display :1 or explicit port 5901. |
| SSH | 2222 |
Maps to container :22; username/password from env. |
| NoMachine NX | 4000 |
Only if image was built with INCLUDE_NOMACHINE=true. Container must be started with --cap-add=SYS_PTRACE (NoMachine's nxnode uses ptrace to spawn virtual sessions; default Docker AppArmor blocks it). |
cp .env.example .env
# edit .env, then:
docker compose up -dWith NVIDIA GPU passthrough (requires NVIDIA Container Toolkit):
docker compose -f docker-compose.yaml -f docker-compose.gpu.yaml up -dSet at docker run/compose time:
| Variable | Default | Purpose |
|---|---|---|
USERNAME |
(required) | Login user; gets passwordless sudo. |
PASSWORD |
(required) | Account password for SSH / RDP / sudo. |
VNCPASSWORD |
(required) | VNC connection password. |
VNC_GEOMETRY |
1920x1080 |
VNC screen size. |
VNC_DEPTH |
24 |
VNC color depth. |
DISABLE_VNC |
0 |
Set 1 to skip VNC server. |
DISABLE_XRDP |
0 |
Set 1 to skip xrdp. |
DISABLE_SSH |
0 |
Set 1 to skip sshd. |
DISABLE_NOMACHINE |
0 |
Set 1 to skip nxserver (only meaningful when image was built with NoMachine). |
docker buildx build --load -t ubuntu-desktop:dev .
# slim variant
docker buildx build --load \
--build-arg VARIANT=slim \
--build-arg INCLUDE_BROWSER=false \
--build-arg INCLUDE_MEDIA=false \
--build-arg INCLUDE_VSCODE=false \
-t ubuntu-desktop:dev-slim .With NoMachine NX server (port 4000) layered in:
make build NOMACHINE=true
# or:
docker buildx build --load --build-arg INCLUDE_NOMACHINE=true -t ubuntu-desktop:dev-nx .NoMachine is opt-in because its license model differs from the rest of the image. Personal use is permitted under the free license; commercial users need an Enterprise license — see https://www.nomachine.com/licensing.
Smoke test:
IMAGE=ubuntu-desktop:dev ./tests/smoke.shXFCE, polkit, dbus services, xrdp's sesman, and several others expect a
working init system. The previous bash-entrypoint.sh approach started a
handful of daemons by hand and then tail -f /dev/null'd — which broke
docker restart, made VNC the only practical session backend, and meant
systemctl reported nonsense.
This image runs /sbin/init directly. First-boot configuration (user
creation, VNC password, sudoers drop-in) runs as the
container-setup.service
oneshot, ordered before ssh.service, xrdp.service, and
vncserver@.service. The service is idempotent, so docker restart desktop
works.
The image does not require --privileged. Required runtime bits are:
--cgroupns=host-v /sys/fs/cgroup:/sys/fs/cgroup:rw(writable cgroup tree)--tmpfs /run --tmpfs /run/lock --tmpfs /tmp--cap-add=SYS_PTRACEonly if NoMachine NX is enabled (itsnxnodeuses ptrace; default Docker AppArmor blocks it — see NoMachine KB DT07S00242)
Why writable cgroup? With cgroup v2's unified hierarchy, systemd inside the
container needs to write to the cgroup it lives in to spawn child slices.
On cgroup v1 you could mount :ro — that's no longer enough on modern
hosts. The trade-off: a container with this mount can manipulate cgroups
visible to it; pair it with reasonable Docker user namespace settings if
that matters for your threat model.
main— releases. Protected, PR-only.dev— integration; PRs target here.- Legacy archived tags:
legacy/gpu,legacy/headless.
See CONTRIBUTING.md.
MIT.