Skip to content

Docker Compose deep dive

This page is the authoritative deep dive on the Compose stack’s real topology. For the step-by-step commands to bring an instance up, see Installation.

docker-compose.yml defines four services:

Service Image Role
db postgres:17-alpine The database. Bound to 127.0.0.1:5432 on the host only, for local admin access (psql, running migrations from the host); never reachable off-box.
web Built from the repo’s Dockerfile The Django app under gunicorn. Bound to 127.0.0.1:8000 for direct host-side debugging; nginx is the real public entry point. Mounts ./backups:/app/backups as a plain host directory (not a named volume) so you can point rsync/rclone at it directly.
nginx nginx:1.27-alpine TLS termination and reverse proxy. Runs with network_mode: host, see below. Its command is overridden to /watch-certs.sh (a custom script), while the base image’s own docker-entrypoint.sh still runs first.
cert-renew tailscale/tailscale:v1.102.3 Opt-in (profiles: ["tailscale"]) automated TLS cert issuance/renewal via Tailscale. Only starts with --profile tailscale.

web’s root key is mounted as a Docker secret (redscribe_root_key, backed by ./secrets/root_key.txt), not an environment variable. See Encryption model for why. db and web both use Compose healthchecks so dependents (web waits on db, nginx waits on web) only start once their dependency is actually ready, not just running. web’s healthcheck hits config.health’s DB-ping view at /health/ using a plain python -c plus urllib one-liner, since curl/wget aren’t installed in the image.

Bringing your own certificate? Skip cert-renew entirely with plain docker compose up -d, and drop fullchain.pem/privkey.pem into ./certs yourself; nginx doesn’t care which path put them there. Using Method 1 instead, add --profile tailscale to start cert-renew alongside everything else.

cert-renew runs as the image’s default root user, deliberately not pinned to a host UID/GID. Root is always trusted by tailscaled for tailscale cert over the bind-mounted socket (the same trust sudo would give it directly on the host), and root also sidesteps a host-side ownership mismatch on a freshly cloned ./certs: Docker auto-creates bind-mount targets as root:root, which has been observed to break a non-root cert-renew with “permission denied” on a brand-new host that had never written into ./certs before.

cert-renew also carries a depends_on: db it has no functional need for, purely to dodge a known Docker Compose race (docker/compose#9054). Without some depends_on, it would be the only service starting immediately alongside db on a clean up, both racing to attach to the just-created default network before the daemon has fully registered it, intermittently failing with failed to set up container networking: network ... not found. Every other service already serializes after something, so this just gives cert-renew the same protection.

nginx’s service runs with network_mode: host rather than a ports: - "80:80" mapping, so it binds the host’s ports 80/443 directly instead of going through Docker’s own port-forwarding.

This matters for RedScribe’s login-lockout and audit logging, which both record the client IP. On a default Docker install, a published-port mapping is relayed through a userspace docker-proxy process that substitutes its own address as the source for every connection. Without host networking, nginx, and everything downstream including audit log entries and lockout tracking, would see a Docker-internal address instead of the real client IP for every request. That’s a host-level Docker daemon quirk that would otherwise need re-fixing on every machine this gets deployed to. Host networking avoids it entirely, with nothing to configure per host. web’s own 127.0.0.1:8000 mapping is unaffected: proxy_pass in the nginx templates targets that address directly, since both nginx and web are now on the host’s own network namespace.

nginx’s actual startup command is /watch-certs.sh (mounted read-only from ./nginx/watch-certs.sh), not bare nginx. It:

  1. Re-runs the ${NGINX_SERVER_NAME} template substitution itself (the same one the base nginx image’s own docker-entrypoint.d scripts do), as belt-and-braces against an observed race on a fresh docker compose up -d where that step sometimes lost the race and left the image’s stock conf.d/default.conf in place instead.
  2. Waits in a loop for /etc/nginx/certs/fullchain.pem and privkey.pem to both exist, so nginx never fails to start just because cert-renew hasn’t issued the first certificate yet, or a BYO cert hasn’t been dropped into ./certs yet.
  3. Before even trying to bind, checks the host’s /proc/net/tcp (this container runs with network_mode: host, so that file really is the host’s socket table) for something already listening on port 80 or 443, and prints a specific diagnostic pointing at Tailscale Funnel/Serve if so. See the Funnel section below for why that’s the most common real cause on a Method 1 deploy.
  4. Starts nginx -g "daemon off;" in the background, then polls the cert file’s MD5 hash every hour and runs nginx -s reload the moment it changes, since nginx caches loaded certificates in its worker process and never picks up a renewed file on its own without a reload.

That hourly check is also why a manually reissued cert can take up to an hour to actually take effect if nginx was already running. Force it immediately with docker compose exec nginx nginx -s reload. See Moving to a new machine for the scenario this most commonly comes up in.

Gunicorn defaults to a single worker process, which means the app never uses more than one CPU core no matter how many the host has. entrypoint.sh auto-sizes --workers to (2 * nproc) + 1 (the standard gunicorn-recommended formula) unless GUNICORN_WORKERS is set explicitly in .env. nproc reflects cgroup CPU limits when the container has any (e.g. docker run --cpus), not just the host’s total core count.

nginx itself already uses worker_processes auto; (the base image’s default), so it was already using every core. This setting only affects the Django app process. See Requirements & sizing for how worker counts map to instance tiers.

Exposing it beyond your tailnet with Tailscale Funnel

Section titled “Exposing it beyond your tailnet with Tailscale Funnel”

By default, Method 1 is reachable only from devices on your tailnet. To make it reachable from the public internet too, use Tailscale Funnel, but forward the connection as raw TCP, not Funnel’s default HTTPS-terminating mode. nginx already terminates TLS itself with the cert cert-renew issued; if Funnel also terminates TLS and hands nginx decrypted HTTP, nginx’s listen 443 ssl; rejects it outright with 400 The plain HTTP request was sent to HTTPS port.

Terminal window
tailscale funnel --bg --proxy-protocol=2 --tcp=443 443

Requires Funnel enabled for your tailnet (same admin-console toggle as HTTPS certs). See Tailscale’s own docs if tailscale funnel refuses to start.

--proxy-protocol=2 matters just as much as --tcp itself. Without it, Funnel still relays the connection correctly, but nginx has no way to recover the real visitor’s IP, since Funnel forwards over loopback, so every request would otherwise show up in the audit log and login-lockout checks as 127.0.0.1 instead of the actual client. nginx’s config already expects this flag: listen 127.0.0.1:443 ssl proxy_protocol; is bound only to loopback (the direct/tailnet listener on the same port is untouched by this) and reads the client IP it conveys.

Terminal window
tailscale serve reset

This state lives entirely in tailscaled, not in Docker, so it survives docker compose down, removing containers/volumes, even wiping every container and rebuilding from scratch. None of that touches it.

Left in place, it also causes a real conflict on the next docker compose up: tailscaled is still holding port 443 for the Funnel forward, so nginx (which needs that same port, see Why nginx uses host networking) fails to bind with bind() to 0.0.0.0:443 failed (98: Address in use) and crash-loops. If you hit that error, tailscale serve status on the host is the first thing to check. watch-certs.sh’s own startup check also looks for this and prints a hint pointing here before nginx even tries to bind.