DocsServers
Security
DokWalt's threat model and how access, secrets, network exposure and containers are protected.
Threat model
DokWalt is a single-admin tool: one person, one or a few servers, many sites.
| It protects against | It does not protect against |
|---|---|
| Internet scanners and attackers reaching the server | Someone who can log in over SSH as you (that is root) |
| Secrets leaking through the repository | Malicious images you choose to deploy |
| Databases and internal ports exposed by accident | Physical access to an unencrypted disk |
A copied dokwalt.db or backup revealing config values |
A compromised computer with an unlocked SSH agent |
| One app reaching another app (database or web service) | Vulnerabilities inside your own applications |
The whole design reduces to two doors: SSH (22) for you, Caddy (80/443) for visitors.
Access control
- Authentication is your SSH key. The CLI opens one SSH connection with your normal settings:
host, user, port, identity files,
ProxyJump(first hop) andStrictHostKeyCheckingare resolved withssh -G, so~/.ssh/configapplies (DOKWALT_SSH_CONFIGpoints it at another file). It signs in withssh-agentkeys first, then your identity files. - Host keys are verified against
~/.ssh/known_hosts. A changed key is always refused ("HOST KEY MISMATCH"). An unknown key is shown with its fingerprint and you are asked, likessh; withStrictHostKeyChecking accept-new(orno) it is accepted without asking, withyesit is refused. Accepted keys are appended toknown_hosts. - No control-plane port. For each API connection the CLI runs
dokwalt dial-stdioin an SSH session; it bridges stdin/stdout to the daemon's Unix socket. It works even when sshd hasAllowTcpForwarding noandAllowStreamLocalForwarding no. - The socket
/run/dokwalt/dokwalt.sockisroot:dokwalt, mode0660: only root and members of groupdokwaltcan talk to the daemon. - The
dockergroup is root-equivalent (docker run -v /:/host …can read and write anything).server initadds your SSH user todockeranddokwalt. Never add other accounts.
Why no extra DokWalt password in v0.1? The admin user is in docker, so any secret DokWalt
checked could be bypassed by that same user with one docker command: prompts without a real
boundary. What actually protects the server is the SSH key, so protect it:
- a passphrase-protected Ed25519 key, or a hardware-backed agent (Secretive, 1Password, YubiKey);
- limited agent lifetimes, e.g.
ssh-add -t 8h, so an unattended computer is not an open door; PasswordAuthentication noandPermitRootLogin noon the server;- no port 22 forwarded from home; reach a home server over a VPN such as Tailscale.
Secrets
- Encrypted at rest. Config values and config snapshots are stored in SQLite encrypted with
XChaCha20-Poly1305. The key, 32 random bytes, is
/var/lib/dokwalt/secret.key(mode0600), created by the daemon at its first start. - Not encrypted: alert webhook URLs (
alerts:add) are stored in clear indokwalt.db;dokwalt alertsmasks them in its output. Treat a leaked database as a leaked webhook. - What encryption buys you: a leaked
dokwalt.dbor a copied backup doesn't reveal config values without the key. It does not protect against root on the running server, which can read the key and every container's environment anyway. - Never written to disk in clear. Rendered compose files under
/var/lib/dokwalt/apps/contain config keys only as pass-through entries (KEY: null) and literal${VAR}references; the values exist only in the environment of thedocker composeprocess the daemon runs. - Masked output. The daemon masks values before they leave it:
dokwalt configshows the first 2 characters followed by•••(values of 4 characters or less fully masked);--revealand--shellprint them in clear, andconfig:get <KEY>prints one value for scripts. - Runtime exposure. Config reaches stateless containers as environment variables (stateful
ones only get what they reference as
${VAR}). Anyone indockercan read them withdocker inspect: one more reason that group stays yours alone. - Exports are plaintext.
dokwalt apps:export file.jsonwrites config in clear text (mode0600, with a warning); without a file it prints to stdout. Move it over SSH, import, delete it. - Backups: store
secret.keyseparately from database backups. Without it, config is unrecoverable, by design. - Nothing secret in the repo. DokWalt adds no file to your project. Load a local
.envonce withdokwalt config:import .envand keep.envin.gitignore.
Network exposure
| Port | Listener | Exposure |
|---|---|---|
| 22/tcp | sshd | public, or restricted by firewall / VPN |
| 80/tcp, 443/tcp, 443/udp | dokwalt-caddy |
public |
| anything else | nothing | never published |
ports:in your compose file are removed (with a warning). Docker-published ports bypass ufw, so this is what keeps a stray5432:5432from exposing a database to the internet.- Any
network_modeother thanbridgeis rejected;container_nameis removed. - Caddy's admin API is a Unix socket (
/var/lib/dokwalt/caddy/run/admin.sock), never TCP port 2019. Its access and TLS logs go to another Unix socket read by the daemon (access.sock) and are only aggregated into metrics, never written to disk. - Databases are never public.
dokwalt db:connectruns the database's own client inside its container (docker execover SSH): no port is opened, on the server or on your computer. Client passwords are passed throughPGPASSWORD,MYSQL_PWDorREDISCLI_AUTHwithdocker exec -e, never on the command line.
Check it yourself on the server:
sudo ss -tlnup # expect: sshd on 22, docker-proxy on 80 and 443 (tcp + udp), nothing elseContainer isolation
- Each app stage has its own private bridge network,
dw-<app>-<stage>. Its services (data, blue and green projects) reach each other by service name, e.g.db:5432. - There is no shared proxy network. The daemon attaches
dokwalt-caddyto every stage network; Caddy is the only container on more than one network. So apps can't reach each other at all, databases or web services, and staging can't reach production. - DokWalt adds no seccomp/AppArmor profile beyond Docker's defaults. Compose security options
(
privileged,cap_add,devices, a Docker socket mount) are kept as written, without a warning: each is root-equivalent, so review images that ask for them. - Absolute host bind mounts are allowed (warning: "host path must exist on the server");
binds of project files (
./x:/y) are rejected. - Recommended in your own files: a non-root
USERin the Dockerfile,read_only: truewhere possible,cap_drop: [ALL], andmem_limitso one app cannot starve the others.
The daemon itself
The daemon runs as root under systemd (it drives Docker and reads /proc, /sys and cgroups),
with no network listener and the unit's hardening options NoNewPrivileges=yes, PrivateTmp=yes,
ProtectSystem=full, ProtectKernelTunables=yes and LockPersonality=yes (see systemctl cat dokwalt). It only accepts requests on its socket, i.e. from you over SSH.
Supply chain
- Release binaries are published on GitHub with a SHA-256
checksums.txt; there is no signature (no cosign) yet. Whenserver initorserver upgradedownloads a server binary, it verifies it againstchecksums.txt("checksum mismatch" aborts). - Docker comes from Docker's official get.docker.com script, never the distro's
docker.io. - Caddy runs from the official
caddy:2-alpineimage (a floating tag, not a digest). It is pulled when the container is first created and recreated only if it isn't running or its compose definition changes. - Built images are tagged by content (
dokwalt/<app>-<service>:<12 hex of the image ID>), so a rollback runs exactly the same bytes, as long as the image wasn't pruned. - Registry images (e.g.
postgres:16) are not pinned by digest: they getpull_policy: missingand are pulled only if absent on the server. Pin a specific tag if that matters. - Builds happen on your computer. No CI system holds credentials; nothing builds on the server.
Private registries:
sudo docker loginon the server (the daemon uses root's Docker config).
No telemetry
DokWalt never phones home and does no update checks; the CLI contacts GitHub only to download
a server binary. The server's outbound traffic is what you configure: Let's Encrypt (Caddy), the
registries you pull from, your alert webhooks (https:// required), OS/Docker repositories.
Updates
dokwalt server upgradere-runs the idempotent bootstrap with the CLI's version: it replaces the binary and restarts the daemon. Apps keep serving; Caddy is recreated only if its compose definition changed (about a 1 s blip). It takes no database backup: make your own first.dokwalt doctorwarns (check Versions) when the CLI and the daemon builds differ.- OS and Docker security fixes:
unattended-upgradeswith a nightly reboot window (seedokwalt docs server).
Checklist
- SSH works with keys only; password and root logins are refused.
- Only your admin user is in
dokwaltanddocker(getent group docker dokwalt). sudo ss -tlnupshows only 22, 80 and 443.secret.keyand backups are stored off the server, apart from each other.- No
.envorapps:exportfile is committed or lying around.
See also: dokwalt docs config, dokwalt docs databases, dokwalt docs server.