Skip to content
Deployment & Operations

Docker Compose deep dive

What each service in docker-compose.yml actually does, why nginx runs with host networking, worker sizing, and exposing an instance via Tailscale Funnel.

This page digs into how the Docker Compose stack actually works under the hood: what each service does, why nginx is set up the way it is, how to size the app’s worker processes, and how to expose an instance beyond your tailnet. For the step-by-step commands to bring an instance up in the first place, 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, for local admin access (psql, running migrations from the host). Never reachable off box.
web Built from the repo’s Dockerfile The app itself, running under gunicorn. Bound to 127.0.0.1:8000 for direct host-side debugging, but 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 or rclone at it directly.
nginx nginx:1.30-alpine Handles TLS and reverse proxies to web. Runs with network_mode: host, see below for why.
cert-renew tailscale/tailscale:v1.102.5 Optional. Issues and renews a TLS certificate automatically via Tailscale. Only starts if you add --profile tailscale.

A few things worth knowing about how these fit together:

  • web’s root encryption key, Django signing key, and database password are all mounted as Docker secrets (redscribe_root_key, django_secret_key, postgres_password, backed by the matching file under ./secrets/), not environment variables — db gets the same database-password secret via Postgres’s own POSTGRES_PASSWORD_FILE convention. See Encryption model and secrets/README.md for why that matters.
  • db and web both have healthchecks, so web waits until db is actually ready (not just running) before it starts, and nginx waits on web the same way. web’s healthcheck pings a small /health/ endpoint that checks the database connection.
  • Bringing your own certificate? Skip cert-renew entirely, just run plain docker compose up -d and drop fullchain.pem and 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.

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

This matters because RedScribe’s login lockout and audit logging both record the client’s IP address. On a default Docker install, a published-port mapping is relayed through a helper process that swaps in its own address as the source of every connection. Without host networking, every request would show up in the audit log and login lockout tracking with a meaningless Docker-internal address instead of the real visitor’s IP. Host networking sidesteps that completely, with nothing to configure per host. web’s own 127.0.0.1:8000 binding still works fine, since nginx and web now share the host’s own network.

nginx doesn’t start with the bare nginx command. It starts a small script that:

  1. Waits for both fullchain.pem and privkey.pem to show up in ./certs, so nginx never fails to start just because cert-renew hasn’t issued the first certificate yet, or you haven’t dropped in your own cert yet.
  2. Checks whether something else on the host is already listening on port 80 or 443, and prints a specific hint pointing at Tailscale Funnel or Serve if so. See the Funnel section below, that’s the most common real cause of this on a Tailscale deploy.
  3. Starts nginx, then checks the certificate file every hour and reloads nginx the moment it changes. nginx caches whatever certificate it loaded at startup and never notices a renewed file on its own without a reload.

That hourly check is also why a manually reissued certificate can take up to an hour to actually take effect if nginx was already running. Force it immediately with:

Terminal window
docker compose exec nginx nginx -s reload

See Moving to a new machine for the scenario this most commonly comes up in.

Gunicorn (the process running the app inside web) defaults to a single worker, which means the app never uses more than one CPU core no matter how many the host has. RedScribe auto-sizes the worker count on startup using the standard gunicorn formula, (2 * cores) + 1, unless you set GUNICORN_WORKERS explicitly in .env. That core count respects any CPU limit you’ve placed on the container (for example docker run --cpus), not just the host’s total core count.

This setting only affects the app process; nginx’s own worker count comes from its base image and isn’t something this project configures. See Requirements & sizing for how worker counts map to instance tiers.

Each service in docker-compose.yml has a memory and CPU ceiling — mem_limit/cpus, the plain Compose Specification keys, not deploy.resources.limits, since this stack doesn’t run under Swarm. Defaults match the “Solo” tier from Requirements & sizing — the documented floor for “just trying it out”. Docker’s own cpus: refuses to start a container at all if the value exceeds the host’s actual CPU count, so the shipped default has to work unmodified on that floor, not just on a beefier host:

Variable Default Service
WEB_MEM_LIMIT / WEB_CPUS 5g / 1 web
DB_MEM_LIMIT / DB_CPUS 2g / 0.5 db
NGINX_MEM_LIMIT / NGINX_CPUS 256m / 0.5 nginx

web gets the larger share, since report export (WeasyPrint/docxtpl) is the most memory-hungry thing this app does, and it’s a synchronous, in-request operation (see Scaling & concurrency model) with no queue to smooth out a burst of exports. Raise these in .env for a bigger tier before you actually need it, a container hitting its own memory limit is killed outright (OOMKilled), not throttled gracefully. Set a value very high (or remove the corresponding lines from docker-compose.yml) to effectively run uncapped, matching the behaviour before these limits existed.

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 mode that terminates HTTPS itself. nginx already terminates TLS with the certificate cert-renew issued. If Funnel also terminates TLS and hands nginx decrypted HTTP, nginx 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

This requires Funnel enabled for your tailnet, the same admin console toggle as HTTPS certificates. See Tailscale’s own docs if tailscale funnel refuses to start.

--proxy-protocol=2 matters just as much as --tcp does. Without it, Funnel still relays the connection, but nginx has no way to recover the real visitor’s IP, since Funnel forwards over loopback. Every request would then show up in the audit log and login lockout checks as 127.0.0.1 instead of the actual visitor. nginx is already configured to expect this flag on its loopback listener, so nothing else needs to change on the RedScribe side.

Terminal window
tailscale serve reset

This setting lives entirely in Tailscale’s own daemon, not in Docker, so it survives docker compose down, removing containers and volumes, even wiping every container and rebuilding from scratch. None of that touches it.

Left in place, it also causes a real conflict the next time you run docker compose up: Tailscale 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 and crash-loops with an “Address in use” error. If you hit that, 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.