Appearance
Containers, Caddy, and clear ports
A database container starts successfully with 0.0.0.0:5432 published. The application works, so the mistake can survive review. A few minutes later, the database is reachable from every address allowed by the cloud firewall. If that firewall changes, the database becomes public without any application diff.
Clear port ownership prevents this class of accident. The edge owns public traffic. Everything else stays on the private container network.
The service sketch
text
Host 80/443
|
Caddy
|
+-- / static UI
+-- /auth/* agent service
+-- /hooks/* signed webhook service
+-- /sync/* sync service
+-- /zero/* replication cache
Private only:
agent:3000 sync:3100 zero:4848 postgres:5432
redis:6379 metrics endpointsThe exact paths will differ. The useful property is that an operator can point to one file and answer which process receives each public request.
Terms
Published port maps a container port onto a host interface. Docker Compose expresses it with ports.
Exposed port documents or makes a port available to other containers without creating a host listener. Compose networking usually allows service-to-service connections without an expose entry.
Same-origin routing keeps the browser UI, API, WebSocket, and service worker under one HTTPS origin.
Forward authentication asks an authentication service whether a request may continue before the edge proxies it to another upstream.
Health endpoint reports a narrow operational fact. It is not a substitute for an end-to-end check.
Containers are process boundaries
Use one container for each process with a distinct lifecycle. A watcher and a sync server may share one backend image while running different commands. This avoids duplicated image builds without hiding two processes behind one supervisor. A migration can use the same image as a short-lived tool profile.
Keep application containers read-only where practical. Give them a temporary filesystem for browser downloads or generated work. Run as an unprivileged user. Add an init process when child-process cleanup matters. Rotate logs at the Docker layer so one noisy dependency cannot fill the disk.
State belongs in named volumes or explicit host paths, not writable image layers. The image should be replaceable at any moment. Postgres data, Redis persistence, replication cache files, and Caddy certificate state have different recovery value, so name each volume for its purpose.
A small production definition can make the public rule mechanically obvious:
yaml
services:
web:
image: registry.example/web@sha256:...
ports:
- "80:80"
- "443:443"
api:
image: registry.example/backend@sha256:...
read_only: true
tmpfs: ["/tmp"]
postgres:
image: postgres:16
volumes: ["postgres-data:/var/lib/postgresql/data"]Only web has ports. A review or automated test can reject any other service that gains one.
Caddy owns the browser boundary
Caddy is the reference choice because automatic TLS, static serving, compression, reverse proxying, and simple health endpoints fit in one process. The choice is explicit, not universal. Nginx or Traefik can do the same job if the team already operates them.
Keep routing same-origin when browser credentials cover several services. Session cookies with the __Host- prefix require HTTPS, no Domain attribute, and root path scope.
Moving the static UI to a different host would change cookie behavior, service-worker scope, Content Security Policy, WebSocket URLs, and push endpoints. "Static files can live anywhere" is false once the browser application has an authentication and offline contract.
A generic Caddy route makes authentication exceptions visible:
txt
app.example.net {
handle /auth/* {
reverse_proxy api:3000
}
forward_auth api:3000 {
uri /auth/check
}
handle_path /stream/* {
reverse_proxy stream:3100
}
handle {
root * /srv
try_files {path} /index.html
file_server
}
}This example intentionally leaves /auth/* outside forward authentication because login must be reachable without a session. Webhooks may also bypass session checks, but only when they verify an HMAC or equivalent signature themselves. A public exception without its own authentication is a hole.
WebSockets and path handling
Reverse proxies usually pass WebSocket upgrades, but path rewriting still causes failures. handle_path /sync/* strips the prefix before forwarding. A plain handle does not. Write down what the upstream expects and test the actual browser handshake.
The replication service can also sit behind a stripped prefix such as /zero. This removes mixed-content and cross-origin problems because the browser connects to the application origin over wss. It also means edge authentication must cover the WebSocket handshake, not only ordinary HTTP requests.
Health is layered
Container health should test the process on loopback. Dependency health should check what the process needs, such as a lightweight Postgres query. Edge health should use a private Caddy endpoint. Public verification should request the real hostname and assert a stable response such as the login redirect.
Do not publish the internal health and metrics listener. Caddy can serve it on a second container-only port. An outbound collector on the Compose network can scrape it without opening another host port.
Choices and alternatives rejected
The design uses Docker Compose because the graph is fixed and the cell runs on one host. Systemd units could run the same processes, but Compose keeps image, network, volume, dependency, and health declarations together.
The design uses one edge container. Publishing each service and relying on the cloud firewall duplicates access policy and makes same-origin browser behavior harder.
The design keeps Caddy's administrative API off public interfaces. Runtime configuration is less valuable here than a small reviewed file.
The design keeps metrics internal. Grafana Alloy sends them outbound. Public Prometheus endpoints offer attackers service names, versions, and workload behavior for no operational gain.
Failure patterns
- A dependency marked
depends_onbut without a health condition can start consumers before the database is ready. - A health check with a long start period can delay deployment failure for many minutes.
- A migration container left in the normal profile may run more than once.
- A wildcard route placed before a webhook route can send signed requests to the wrong service.
- An edge restart without persistent Caddy state can trigger certificate churn.
- A container with Docker socket access can control the host even when the mount text says read-only.
Test rendered Compose, not only YAML syntax. Environment interpolation, profiles, and merged anchors determine the configuration Docker actually receives.
Port review checklist
Previous: Chapter 32, "Hetzner from zero to cell".
Next chapter
Chapter 34, "Build once, deploy by digest", moves compilation out of the host and makes releases reproducible.