Security model
What Dockflow protects and what it does not - SSH host keys, connection strings, Docker access, the port filter, secrets, the server's state, verified downloads and signed webhooks.
Dockflow deploys by reaching your server's Docker Engine over SSH. This page says plainly what that gives access to, which keys matter, what Dockflow checks for you, and where your secrets end up.
Docker access is root access
Whoever can use the Docker Engine on a server is root on it: one docker run with a bind mount of / is enough. The SSH user Dockflow deploys with must be able to use Docker. So that user, and every key that logs in as it, is root-equivalent on the server.
Sudo rules would not contain this, so Dockflow does not rely on them. Instead, limit who holds the keys:
- Use a dedicated deploy user and a dedicated key per environment, not your personal key.
- Store the key only where deploys run: your CI's secrets, and
.env.dockflowon the machines of the people who deploy by hand. - Remove the key from the server's
authorized_keyswhen someone leaves or a secret store is compromised, and print a new connection string.
SSH and host keys
Every connection verifies the server's host key. Nothing is trusted on first use: an unknown key is an error, not a prompt.
The key is checked against, in this order:
- the fingerprint pinned in the connection string, or under
host_key:indockflow.yml; - otherwise, your
~/.ssh/known_hosts.
version: 1
app: shop
environments:
production:
server:
host: 203.0.113.10
user: deploy
host_key: "SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s"dockflow server connection connects first, verifying the key against --host-key or known_hosts. It then writes the key it verified into the connection string.
Get the fingerprint from the server itself, over a channel you trust (your provider's console, for example):
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pubA key that does not match fails with ssh.host_key_mismatch (exit code 4). If the server was reinstalled, verify the new key on the server and print the connection string again. If it was not, do not deploy: something may stand between you and the server.
Connection strings
A connection string is the base64 of a small JSON document. It holds the server's address, user and host key fingerprint, and the private key (or, for servers that only accept passwords, the password).
Base64 is an encoding, not encryption. Anyone who has the string can log in to the server as the deploy user, which means root.
- Store it as a CI secret,
<ENV>_CONNECTION(PRODUCTION_CONNECTION), or in.env.dockflow, which must stay out of git. Never commit it or paste it in an issue. - Dockflow reads it like any other secret and masks it, and the key and password it holds (each line of a key), in its output.
- Do not pass it as
--connection <string>: arguments are visible to every user of the machine in the process list.--connection @<file>and--connection -(standard input) read it without putting it on the command line, and Dockflow warns when it is given as is. - Prefer a key to a password.
If you would rather not keep the key in a secret, describe the server under server: in dockflow.yml and give the key through DOCKFLOW_SSH_KEY or ssh-agent instead. See Environment variables.
What listens on the server
| Component | Listens on | Notes |
|---|---|---|
| The switch (Caddy) | 80 and 443 on every address with the builtin edge; 127.0.0.1:8080 by default with the external edge | Its admin API listens on no port: only on a unix socket, in a volume that only the switch and Dockflow's agents mount. Your app's containers share networks with the switch and cannot reach it. |
| The registry | 127.0.0.1:5959 only | Never reachable from outside, so it needs no authentication. The CLI pushes images to it through the SSH connection. |
| Agents | Nothing | One container per run, started through the Docker API, gone when the run ends. Agents and the watchdog share dockflow-system, an internal bridge network with no route out. |
Your own services listen where their Compose ports: say. Docker's published ports bypass host firewalls such as ufw and firewalld: without the port filter below, 5432:5432 is open to the internet.
Publish internal ports on loopback, 127.0.0.1:5432:5432, or not at all. Services of one app reach each other by name on their Compose networks.
The port filter
server init installs a filter that drops new connections from outside to the ports Docker publishes, except:
- the ports apps list under
firewall.public_ports, open to everyone or to theirfromranges; - 80 and 443 for the switch, with the
builtinedge, and from Cloudflare's ranges only with thecloudflareedge; or the port of--switch-listenwith theexternaledge when it is not on loopback; - SSH.
It catches a 5432:5432 written out of habit: the port stays published, but only the server itself and its containers reach it. The plan warns about each port published on every interface that no app lists.
The filter applies to every container of the server, Dockflow's or not. If you need a port open that no app lists, publish it on loopback, or opt out of the filter.
To opt out, run server init --no-port-filter. server init --no-port-filter=false turns the filter back on.
How the filter works
The filter is two iptables chains, for IPv4 and IPv6:
DOCKFLOW-FILTER, that Docker'sDOCKER-USERchain jumps to, sees the connections Docker translates to a container. It matches the port a connection was sent to, before Docker translated it. It also drops new connections routed from outside straight to a container's address, which only a neighbour on the server's network could send: containers are reached through their published ports.DOCKFLOW-INPUT, thatINPUTjumps to, sees the connections Docker'sdocker-proxyreceives on the host itself. That is how a port published without an address,5432:5432, is reached over IPv6 when the app's network has no IPv6: such connections never go throughDOCKER-USER. The chain drops those to the ports your apps and the switch publish, unless allowed. So a port closed over IPv4 is closed over IPv6 too, and thecloudflareedge holds over IPv6.- A port published without an address and without a host port (
ports: ["3000"], a random port) is bound to IPv4 only: the filter cannot know that port in advance. - They leave alone the traffic of Docker's bridges, loopback, and the ports the host itself listens on (your own nginx or sshd).
- A deploy, a rollback or a
destroythat changes the ports applies the new rules through a short helper container on the host's network. It writes them to/etc/dockflow/filter.rulesandfilter6.rules. - The
dockflow-filtersystemd unit loads the rules again at boot, before Docker.
The helper container runs the host's own iptables (legacy or nft) with NET_ADMIN, NET_RAW and SYS_CHROOT. It replaces only the filter's chains, atomically, and leaves Docker's rules and your own alone.
- When Docker does not send forwarded traffic through
DOCKER-USER, the filter fails withfilter.inertrather than doing nothing. That is the case with Docker'snftablesfirewall backend, or with"iptables": false. Use Docker's default iptables backend, or--no-port-filterand your own rules. - When the IPv6 rules cannot be applied, the filter fails if the server has an IPv6 address, and warns otherwise.
- Over IPv6, the filter knows only the ports of Dockflow's apps and of the switch: containers Dockflow did not deploy are filtered over IPv4 only.
- With the filter, a Compose network with routed addresses (
gateway_mode_ipv4: routed) is not reachable from outside, since its containers are reached by their own address.
Cloudflare's ranges
With the cloudflare edge, Cloudflare's ranges are downloaded at server init, and again every week by the dockflow-cloudflare.timer unit. A list is refused unless every line is a range of its family. A failed download keeps the previous ranges.
With the external edge and your own nginx, the filter does not restrict the host's ports 80 and 443 to Cloudflare. Use the nginx plugin's cloudflare input, which trusts the real-IP header from Cloudflare's ranges only, and your own firewall rules.
Secrets
Secrets come from your CI's variables (PRODUCTION_*, or DOCKFLOW_SECRETS) and from .env.dockflow.
Dockflow reads only the variables with the environment's prefix, plus DOCKFLOW_SECRETS. PATH, HOME and the rest of your environment never become secrets.
For production, the key X resolves to PRODUCTION_X when it exists, otherwise to X. So a production-only value never leaks into staging by its name.
What Dockflow guarantees:
- Masked output. From the moment it is read, every secret value is replaced with
***in everything Dockflow shows or stores: events, build output, hook and job output, errors,config show, and the--jsonstream. Values shorter than 4 characters are not masked, because they would hide ordinary words. Output is shown line by line, so each line of a multi-line secret (a PEM key) is masked on its own too, when it has at least 8 characters. - No command lines. Secrets never appear in a command line, on your machine or on the server, where other users could see them in the process list.
- Not in the agent's environment. The agent receives its run on its standard input, not through environment variables.
- A missing secret is an error, never an empty string.
What you control:
- Container environment. A secret you interpolate into a service,
${DB_PASSWORD}inenvironment:, is part of that container's configuration. Anyone with Docker access on the server sees it withdocker inspect. It is also recorded in the release record under/var/lib/dockflow, readable by root only. - Files. A secret you render into a template or an upload is written into that file on the server.
- Interactive commands.
dockflow exec,dockflow runanddockflow sshconnect your terminal to the command. Their output is the command's own and is not masked. - Secret files. Compose
secrets:are written to/run/dockflow/secrets/<app>-<env>/, in a0700directory, and mounted read-only. That directory is on a tmpfs: Dockflow refuses to write them elsewhere. They appear neither indocker inspectnor in release records, which keep only their hash. A copy of each is kept in/var/lib/dockflow/apps/<app>-<env>/secrets/, readable by root only, so the watchdog can write them again after a reboot.secrets revokeanddestroydelete the copies with the files. See Secrets and configs. - SOPS files. Secrets can be kept in git, in a SOPS file encrypted with age.
- The CLI decrypts it in memory and never writes it in clear. The exception is
secrets edit, which uses a temporary file readable by you only, and deletes it. - It checks the file's MAC on every read, so a value changed without the key is refused.
- The age key comes from
SOPS_AGE_KEY,SOPS_AGE_KEY_FILE, or thesops/age/keys.txtof your user's configuration directory, as for thesopscommand. In CI, give it as a secret of its own, never in the repository.
- The CLI decrypts it in memory and never writes it in clear. The exception is
State on the server
Dockflow's state lives in /var/lib/dockflow, a 0700 directory owned by root, with 0600 files. Only the agent writes there.
It holds:
- the release records, which contain your services' full configuration, including interpolated values;
- the copies of the Compose secret files.
dockflow server check fails when the directory is not 0700. A backup of the server's state (dockflow backup run --server) leaves out the backups' passwords. During a restore, the snapshot's data also sits in staging volumes (volumes) or in a temporary file of the database's container (MySQL, MongoDB, Redis), removed at the end. See Server layout.
Verified downloads
- The installer (
install.sh) downloads the binary from the GitHub release and checks it against the release'sSHA256SUMS. When cosign is installed, it also checks the Sigstore signature ofSHA256SUMS, bound to this repository's release workflow. Without cosign, it says that it checked the checksum only, or stops with--require-signature. Install cosign to get the signature check. The GitHub Action installs cosign and requires the signature. - The other installers.
install.ps1checks the binary asinstall.shdoes.- Homebrew checks it against the checksum in the formula, written by the release workflow from
SHA256SUMS. - The npm package
@dockflow-tools/clihas no install script. It runs the binary of its per-platform optional dependency, built from the release's binaries and published with npm provenance by the release workflow.
- The agent binary. When the CLI is not itself the Linux binary for the server's architecture, it downloads the agent binary over HTTPS, from the release of its own version.
- A released CLI is built with the SHA-256 of that release's agent binaries, and checks the download against it. So the agent comes from the same build as the CLI: its origin is the CLI's, which the installer checked.
- Only after the check is the binary cached, in
dockflow/<version>/under your user's cache directory, and sent to the server. - A mismatch stops with
agent.checksum_mismatch(exit code 4), and nothing is sent. - A CLI built without these checksums (from source, with a version) checks against the release's
SHA256SUMS. DOCKFLOW_RELEASES_URLpoints the download at a mirror. With a released CLI, which carries the checksums, the check is the same, so a mirror cannot substitute another binary. A CLI built without them reads the checksum from the mirror's ownSHA256SUMS, so that mirror could serve another binary: use one only with a released CLI, or a mirror you trust as much as the CLI.- A cached binary is checked again at every use, against the same checksum, and downloaded again when it does not match (an
agent.cache_mismatchwarning).
- Your own agent binary.
DOCKFLOW_AGENT_BINARYreplaces the download with a binary you provide. Dockflow does not check it: use it only with binaries you built or verified yourself.
Remote builds
With build.builder: remote, the build runs on the server's Docker. The CLI opens a local unix socket forwarded to the server's Docker socket, and points the build at it.
Whoever can connect to that socket controls the server's Docker. So the socket lives in a temporary directory only your user can enter (0700), and is removed when the build ends.
Raw Caddy directives
A route's caddy directives run in the switch as written, with the switch's own access. They can read the files the switch's container sees, and reach its admin socket, through which the routes of every app on the server can be changed. Dockflow checks that they parse, not what they do.
Like hooks, they are the project author's responsibility. Review them as you review code. On a server shared by several projects, deploy only projects whose authors you trust with the routing of the whole server.
Signed webhooks
Webhooks under notify: can carry a secret:
version: 1
app: shop
notify:
- url: https://hooks.example.com/dockflow
secret: ${WEBHOOK_SECRET}
environments:
production: {}Dockflow then signs each request's body with HMAC-SHA256, and sends the signature in the X-Dockflow-Signature header, as sha256=<hex>.
- On the receiving side, compute the HMAC of the raw body with the same secret, and compare it with the header in constant time. Examples in Node.js, Python and Go are in Notifications.
- Reject requests whose signature does not match.
- Use an
https://URL, so the payload and its signature are not readable on the way. - Redirects are not followed: the payload, its signature and the webhook's
headersare sent to itsurlonly.
The deploy user and its key
dockflow server init creates the deploy user dockflow, in the docker group, and gives it a key:
- Generated by the CLI (ed25519) and never written to disk: the private key exists only in the connection string printed at the end.
- With
server init <admin>@<host>, it is generated on your machine and never reaches the server. - With
sudo dockflow server initon the server, it is generated there, in memory, and shown in that terminal only.
- With
- Installed in
~dockflow/.ssh/authorized_keyswithrestrict,port-forwarding,pty. This turns off agent and X11 forwarding, and re-enables only what Dockflow uses. - One per run. Running
server initagain adds a new key, so each environment or CI system can have its own. Remove a key you no longer use fromauthorized_keys.
The administrator's account is used for this run only, and its credentials are never stored. Provisioning runs as one shell script, sent over the administrator's SSH session. The script is built with a command builder that quotes every value. Nothing is left on the server but what it installs.
dockflow server cloud-init generates the deploy key and the server's ed25519 host key on your machine. The user-data holds:
- the deploy key's public part only;
- the server's private host key, so that the connection string can pin it before the server exists.
Keep the user-data as you would the server's disk. -o user-data.yml writes it with mode 600, and makes an existing file 600 too.
The provider's metadata service serves the user-data to whatever runs on the server, at 169.254.169.254, for as long as the server exists. So on a server prepared by server cloud-init, the port filter drops what containers on Docker's bridges send to the metadata service (169.254.169.254, and AWS's fd00:ec2::254). The host itself and containers on the host's network still reach it: they are root on the server anyway. An app that needs the metadata service, for a cloud role's credentials for example, cannot reach it from a bridge network there; give it its credentials another way, or opt out of the filter with --no-port-filter.
When Dockflow installs Docker, it checks that the key download.docker.com gives is Docker's signing key, by its fingerprint 9DC8 5822 9FC7 DD38 854A E2D8 8D81 803C 0EBF CD88, and that no other key comes with it. Otherwise it stops with provision.docker_key, and installs nothing.
What provisioning sets up
server init <admin>@<host>, sudo dockflow server init and server cloud-init rotate Docker's logs when they install Docker. They leave SSH's settings alone: the server's security is yours.
With --harden-ssh, they also add /etc/ssh/sshd_config.d/01-dockflow.conf: no password authentication, root by key only.
- You are never locked out. While the administrator's account has no authorized key, passwords stay accepted. When that account is root, nothing is written at all, since root logs in with its password. The output says so: add your key, then run
server initagain. - It comes first. sshd keeps the first value it reads, and some providers' images ship a drop-in that turns passwords on (
50-cloud-init.conf).01-dockflow.confis read before it. - It is checked. After writing the file, provisioning asks sshd for the values it uses (
sshd -T). A setting that did not take effect, because a file read earlier sets it, is a warning, not a success.
Servers prepared by an earlier version have /etc/ssh/sshd_config.d/50-dockflow.conf, which stays where it is. To remove it, run sudo rm /etc/ssh/sshd_config.d/50-dockflow.conf && sudo systemctl reload ssh (sshd on some distributions).
The rest of the server's administration stays yours: fail2ban, firewall rules outside Docker, automatic updates, time synchronisation. Ubuntu's and Debian's cloud images already turn on automatic security updates and time synchronisation.
Two options of server init touch access:
--from 203.0.113.0/24restricts the deploy key to source addresses, withfrom=inauthorized_keys.--nginxgives the deploy user two sudo rules,nginx -tandsystemctl reload nginx, for the nginx plugin. This is a convenience, not containment, since the deploy user can reach Docker.
Checklist
- One deploy user and one key per environment, stored only as
<ENV>_CONNECTIONin CI and in a gitignored.env.dockflow. - Host keys pinned, checked on the server before you trust them.
- Internal ports published on
127.0.0.1, or not published; public ones listed underfirewall.public_ports. - cosign installed where you run the installer.
- A secret on every webhook, verified by the receiver.