📚 Docs

App container confinement (shell hardening)

The Hetzner host applies a confinement profile to every app on each deploy. Browser sessions execute inside the selected app container, as an explicit unprivileged UID/GID (32767 by default), never in a host shell. The default workspace is terminal; shell only serves the frontend.

Enforcement

Component Host path or repository source
Runtime profile applier scripts/paas-harden-app.sh → /usr/local/bin/paas-harden-app
Dokku pre-deploy hook scripts/paas-hardening-pre-deploy.sh → plugin paas-hardening/pre-deploy
Seccomp profile config/paas-app-seccomp.json → /etc/dokku/paas/seccomp-app.json
Operator shell helper scripts/paas-app-shell.sh → /usr/local/bin/paas-app-shell
Initial installation templates/bootstrap.sh.tftpl

Deploy/run containers receive --cap-drop=ALL, --security-opt=no-new-privileges:true and the vendored Moby default seccomp profile. If that file is missing, the applier warns and Docker's built-in default still applies. Shells force an unprivileged runtime user even if an image's default changes; image authors must also choose an appropriate non-root application user.

Limit Ordinary apps WebUI
CPU 1 vCPU 1.5 vCPU
Memory (no extra swap) 512 MiB 1536 MiB
PIDs 256 512
Temporary filesystem 256 MiB, noexec/nosuid/nodev Same

The writable container layer has no disk quota on the host's overlay2/ext4 storage. Monitor disk usage; a bounded temporary filesystem is not a full disk-exhaustion defense. Persistent host-backed storage and datastores require separate capacity management and backups.

Important trust exceptions

App containers do not mount the Docker socket or host process filesystem. The host shell backend does require Docker CLI/socket access through its Docker-group service user: that service is a privileged control-plane component, not an untrusted app.

The terminal workspace mounts a shared read-only deploy key at /app/.ssh. Read-only prevents editing the mount, not use or theft of the credential. It can administer every Dokku app, including inspecting app configuration. Browser ?app= selection is not a Dokku permission boundary. Never print/copy the private key or inspect unrelated apps.

Every Dokku app receives OPEN_TERMINAL_API_URL and the same OPEN_TERMINAL_API_KEY. The private API on http://terminal.web:5000 uses the host-managed paas-terminal-api network, with no public domain, proxy or published host port. Other Docker containers may still reach its listener; protected routes require the shared key. Network privacy does not provide per-app authorization. A compromised participating app can access the same terminal workspace.

Browser shells start with a scrubbed environment. The installed systemd unit allowlists OPENROUTER_API_KEY,OPENROUTER_MODEL from terminal runtime config. It does not directly pass the private terminal API key. That omission does not conceal secrets from terminal users: their UID matches the API process and permits reading its environment, and the shared deploy key permits reading app config. OpenRouter calls use an explicit API key, not server identity. Only trusted operators should receive workspace, API or WebUI terminal access.

Verification and operation

On the host, inspect a known target container:

sudo docker inspect <app>.web.1

Check CapDrop, SecurityOpt, Memory, NanoCpus, PidsLimit and Binds; do not publish the output, which can contain runtime credentials. In a shell, id must be non-root and /var/run/docker.sock must be absent. Do not test limits with resource-exhaustion payloads on a live shared host.

Profiles apply automatically to new apps and future deploys. An operator can reapply with sudo paas-harden-app --all, followed by a deliberately scheduled sudo dokku ps:rebuild <app> for each intended app (this replaces containers). Use sudo paas-app-shell <app> for an operator-started unprivileged app shell. Tune limits in the applier and redeploy deliberately.

See Web shell for authentication and audit details, and Getting started for deployment.