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.
The services
Section titled “The services”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 —dbgets the same database-password secret via Postgres’s ownPOSTGRES_PASSWORD_FILEconvention. See Encryption model andsecrets/README.mdfor why that matters.dbandwebboth have healthchecks, sowebwaits untildbis actually ready (not just running) before it starts, andnginxwaits onwebthe same way.web’s healthcheck pings a small/health/endpoint that checks the database connection.- Bringing your own certificate? Skip
cert-renewentirely, just run plaindocker compose up -dand dropfullchain.pemandprivkey.peminto./certsyourself.nginxdoesn’t care which path put them there. Using Method 1 instead? Add--profile tailscaleto startcert-renewalongside everything else.
Why nginx uses host networking
Section titled “Why nginx uses host networking”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.
Certificate reload behavior
Section titled “Certificate reload behavior”nginx doesn’t start with the bare nginx command. It starts a small
script that:
- Waits for both
fullchain.pemandprivkey.pemto show up in./certs, songinxnever fails to start just becausecert-renewhasn’t issued the first certificate yet, or you haven’t dropped in your own cert yet. - 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.
- 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:
docker compose exec nginx nginx -s reloadSee Moving to a new machine for the scenario this most commonly comes up in.
CPU / worker count
Section titled “CPU / worker count”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.
Resource limits
Section titled “Resource limits”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.
tailscale funnel --bg --proxy-protocol=2 --tcp=443 443This 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.
Turning it back off
Section titled “Turning it back off”tailscale serve resetThis 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.