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_proxy must 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_proxy network, 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_SENDER disables mail. Messages print to the console instead. Note that deploy.sh still requires these keys to be present and non-empty, so a mail-less deployment means either filling them with placeholders or editing the script's REQUIRED_VARS.
  • S3 — blank keys force FILE_STORAGE_BACKEND to local even if set to s3. Uploads land in the ./media bind 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 to False, but .env.example ships True
  • [ ] Fresh SECRET_KEY, not copied from another deployment
  • [ ] CSRF_TRUSTED_ORIGINS set to your real origins
  • [ ] .env not committed, permissions restricted
  • [ ] Both DNS records present, or the www clause 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

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