Deployment
Production deployment of Pengin-Pi-3 with Docker Compose, behind an externally managed Traefik instance.
For a local development setup, see Install. For what the security layer enforces, see Architecture.
The Traefik prerequisite
Read this before anything else. The compose file does not start Traefik.
docker-compose.yml defines five services — postgres, web, nginx,
ferretdb, and redis. The nginx service attaches to an already-running,
externally managed Traefik instance through Docker labels and a shared
external network named root_proxy. Traefik itself, and its certificate
storage, live outside this project.
This means:
- Traefik must already be running on the host
- A Docker network named
root_proxymust exist and Traefik must be on it - Let's Encrypt must be configured on that Traefik instance with a
certificate resolver named
letsencrypt
If the network doesn't exist, docker-compose up fails immediately. If
Traefik isn't on it, the containers start and nothing routes.
Verify before deploying:
docker network ls | grep root_proxy
docker network inspect root_proxy
If you are deploying to a host with no Traefik, stand that up first. It is a prerequisite of this project, not a part of it.
Prerequisites
- Docker Engine and the Docker Compose plugin
- Git
- A running Traefik instance on the
root_proxynetwork, as above - A domain name with DNS pointed at the host
- Ports 80 and 443 open on the host firewall
1. Clone
The convention is /opt/pengin-pi-3, with code, compose file, and .env all
in one directory:
sudo git clone https://github.com/Pengin-Open-Source/pengin-pi-3.git /opt/pengin-pi-3
cd /opt/pengin-pi-3
2. Configure
cp .env.example .env
Edit .env. Never commit it.
Required
deploy.sh refuses to run without these:
| Variable | Notes |
|---|---|
| SECRET_KEY | Generate a fresh one. Do not reuse across deployments. |
| DB_NAME, DB_USER, DB_PASSWORD | Postgres credentials |
| FERRETDB_DB | Leave as postgres — see below |
| DB_CONTAINER, WEB_CONTAINER, REDIS_CONTAINER | Container names |
| URL, URL2 | Domains, used for routing and ALLOWED_HOSTS |
| SES_* | Mail — see the note on blanks below |
Optional, with graceful fallbacks
Blank values degrade rather than crash:
- SES — blank
SES_USERNAME_SMTP/SES_PASSWORD_SMTP/SES_SENDERdisables mail. Messages print to the console instead. Note thatdeploy.shstill requires these keys to be present and non-empty, so a mail-less deployment means either filling them with placeholders or editing the script'sREQUIRED_VARS. - S3 — blank keys force
FILE_STORAGE_BACKENDtolocaleven if set tos3. Uploads land in the./mediabind mount. - reCAPTCHA — blank keys hide the widget and skip verification, rather than rejecting every submission.
Two settings that will catch you
FERRETDB_DB is not your application database. DocumentDB's extension,
which FerretDB depends on, installs into exactly one database via pg_cron
— that one. Your app's database (DB_NAME) is created alongside it in the
same instance and volume by docker-entrypoint-initdb/01-create-app-db.sh
on first boot. Leave FERRETDB_DB as postgres unless you have a specific
reason not to.
The Traefik router rule requests a certificate covering both ${URL} and
www.${URL}. If only the bare domain has a DNS record, Let's Encrypt
validation fails on the www name and no certificate is issued at all.
Traefik falls back to its self-signed default with no error other than in its
own logs — the symptom is a browser certificate warning, not a failed deploy.
Either create both DNS records, or drop the || Host(\www.${URL}`)` clause
from the router rule for a single-domain deployment.
3. Deploy
sudo ./deploy.sh
The script validates every required key in .env, then runs
docker-compose down followed by docker-compose up --build -d. It halts
before touching Docker if anything is missing or still set to the literal
placeholder SECRET.
Or do it directly:
sudo docker-compose up --build -d
First boot builds the image, initializes the Postgres volume, runs the init
script, then collectstatic and migrate before uWSGI starts. Give it a
minute.
4. Create a superuser
docker-compose exec web python manage.py createsuperuser
Then follow Quickstart — a fresh install has no home slug, so the site
renders blank until you create one.
What the stack does
postgres
Image: ghcr.io/ferretdb/postgres-documentdb. Not stock Postgres — the
image bakes in the initialization scripts that install the documentdb_api
extension. Substituting postgres:17-alpine produces a stack that starts
cleanly and fails every document operation with
schema "documentdb_api" does not exist.
Data lives in the postgres_data named volume. Healthchecked with
pg_isready; web waits on it.
The 01-create-app-db.sh script is mounted as a single file, not a
directory. A directory bind mount would shadow the image's own baked-in init
scripts and break the extension install. It's numbered 30- so it runs after
them.
web
The Django app under uWSGI — 4 processes, 2 threads, HTTP on 8000, exposed to
the internal network only. Built from a two-stage Dockerfile on
python:3.14.4-slim: wheels compiled in the builder stage, only libpq5 in
the runtime image.
Startup command runs collectstatic --noinput, then migrate, then uWSGI.
Migrations run automatically on every container start — see the upgrade
notes below.
Mounts: ./media (read-write, uploads), static_volume (shared with nginx),
nginx_blocklist.conf (read-only, for the middleware).
nginx
Fronts web, serves static and media from the shared volumes, and carries
the Traefik labels. Listens on port 80 only — TLS terminates at Traefik.
ferretdb
MongoDB wire protocol over the same Postgres instance. Internal only.
redis
Django cache backend and the analytics write buffer. Data in the redis_data
volume.
The Nginx layer
nginx.conf does real work and is worth understanding before you modify it.
Real IP extraction. set_real_ip_from trusts the three RFC1918 ranges,
reads X-Forwarded-For, and sets real_ip_recursive on. Without this every
visitor appears to come from the Traefik container. The proxy blocks also
forward CloudFront-Viewer-Address, which is the header the application's
IP resolver prefers when present.
Rate limiting. Three zones:
| Zone | Rate | Applies to |
|---|---|---|
| global_req_limit | 10 r/s, burst 20 | Everything |
| auth_req_limit | 2 r/s, burst 5 | /login/, /signup/, /generate-prt/, /reset-password/ |
| global_conn_limit | 15 concurrent | Everything |
Limits return 429. This is separate from and additional to the Django-layer
rate limiting in util/security/ratelimit.py.
Probe blocking. Hidden files, scanner extensions (.php, .env, .git,
.bak, and friends), and CMS probe paths (wp-admin, roundcube, and so
on) all return 444 — connection closed, no response.
Access logging is deliberately left on for blocked traffic. Blocking a
request without logging it means Fail2ban can never see the IP to ban it.
This is an easy and common mistake; don't "clean up" those blocks by adding
access_log off to them. Logs are bind-mounted to ./logs/nginx.
ACME challenges are exempted at /.well-known/acme-challenge/ so
certificate renewal isn't rate-limited or blocked.
client_max_body_size is 100M. Raise it if your uploads are larger; Django
and uWSGI limits apply on top.
The blocklist
nginx_blocklist.conf ships empty and is read by two things: Nginx, as an
include, and HardenedBlocklistMiddleware in the Django app. One file, two
enforcement points — an IP banned there is rejected at the edge and again at
the application layer if a request somehow gets through.
Add entries as deny <ip>; lines. This is the file an edge Fail2ban setup
should write to. Reload Nginx after changes:
docker-compose exec nginx nginx -s reload
Operations
Logs
docker-compose logs -f web
docker-compose logs -f nginx
docker-compose logs -f postgres
Status
docker-compose ps
Django shell
docker-compose exec web python manage.py shell
Stop — containers down, named volumes preserved:
sudo docker-compose down
docker-compose down -v destroys the volumes, and with them your database.
Update
cd /opt/pengin-pi-3
sudo git pull
sudo docker-compose up --build -d
Upgrade notes
Migrations run automatically on container start. Convenient for a single
host; a hazard to know about. With 4 uWSGI processes the migrate step runs
once, before workers start — but if you scale to multiple web containers,
they will race. Split migrations into a separate step before the rollout if
you get there.
Back up before upgrading. Relational and document data share one Postgres instance, so one dump covers both:
docker-compose exec postgres pg_dumpall -U "$DB_USER" > backup.sql
Take a fresh backup before any git pull that includes migrations. See
Backup and Restore.
There is no zero-downtime path. up --build -d recreates the web
container. Expect a short outage.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| network root_proxy not found | Traefik isn't running, or isn't on that network |
| Traefik 404 | Router rule doesn't match the request's Host header — check URL/URL2 |
| 502 through Traefik | web failed to start; check its logs |
| Browser certificate warning | www DNS record missing — see the note above |
| schema "documentdb_api" does not exist | Wrong Postgres image, or the init mount shadowed the baked-in scripts |
| DisallowedHost | Domain missing from URL/URL2 |
| All visitors share one IP in analytics | real_ip config altered, or a proxy in front isn't forwarding headers |
| Uploads 413 | client_max_body_size |
| Mail silently not sending | SES keys blank — check web logs for the skip notice |
More in Troubleshooting.
Hardening checklist
- [ ]
DEBUG=False— the compose file defaults it toFalse, but.env.exampleshipsTrue - [ ] Fresh
SECRET_KEY, not copied from another deployment - [ ]
CSRF_TRUSTED_ORIGINSset to your real origins - [ ]
.envnot committed, permissions restricted - [ ] Both DNS records present, or the
wwwclause removed - [ ] Fail2ban configured to write
nginx_blocklist.conf - [ ] Host firewall allows only 80 and 443
- [ ] Backups scheduled and a restore tested
- [ ] reCAPTCHA keys set if the site accepts anonymous submissions
Contents
- Deployment
- The Traefik prerequisite
- Prerequisites
- 1. Clone
- 2. Configure
- Required
- Optional, with graceful fallbacks
- Two settings that will catch you
- 3. Deploy
- 4. Create a superuser
- What the stack does
- postgres
- web
- nginx
- ferretdb
- redis
- The Nginx layer
- The blocklist
- Operations
- Upgrade notes
- Troubleshooting
- Hardening checklist
Pages Here
No sub-pages yet.
Page Info
Wiki: Docs
Created on Sep 20, 2026 by Tobu Pengin, L.L.C.
Maintainers
| Editor | Last Activity |
|---|---|
| Tobu Pengin, L.L.C. creator | Sep 20, 2026 |