Deployment

Production deployment of Pengin-Pi-3 with Docker Compose, behind Traefik — either your own, or the bundled standalone option.

For a local development setup, see Install. For what the security layer enforces, see Architecture.


One-command install

For a fresh Fedora, Debian/Ubuntu (WSL2 included), or macOS host, use the installer scripts shipped in the release instead of the manual steps below:

sudo ./deploy_fedora.sh          # Fedora / dnf-based
sudo ./deploy_debian_ubuntu.sh   # Debian, Ubuntu, or WSL2 running either
./deploy_mac.sh                  # macOS - do not use sudo, it prompts itself when needed

There's no Windows script — use WSL2 with deploy_debian_ubuntu.sh there.

They install Docker CE and the Compose v2 plugin from Docker's own repository (not your distro's, which tends to lag), install and configure Fail2ban with the project's baseline profile (written to /etc/fail2ban/), ask where to install (default /opt/pengin-pi-3) and copy the project there, then hand off to deploy.sh — covered in step 3 below — which also asks whether you already have your own Traefik on this host or want the bundled standalone option covered in the next section.

The rest of this page is what those scripts automate: useful for a manual setup, understanding what's actually happening, or troubleshooting.


The Traefik prerequisite, or the bundled alternative

Read this before anything else. docker-compose.yml does not start Traefik itself.

docker-compose.yml defines five services — postgres, web, nginx, ferretdb, and redis. The nginx service attaches to Traefik through Docker labels and a shared external network named root_proxy — but that Traefik has to come from somewhere. Two options:

You already run your own Traefik/reverse proxy on this host (what the project's own production hosts do, each fronting more than one project): make sure it's on a Docker network literally named root_proxy, with a letsencrypt certificate resolver configured, then skip to Clone below. deploy.sh asks, and on "yes" just confirms the network exists rather than touching Traefik.

You don't — the common case for a fresh, single-project host — docker-compose.traefik.yml, shipped alongside docker-compose.yml, stands up a minimal Traefik with Let's Encrypt configured and owns the root_proxy network, so the app stack has something to attach to:

docker compose -f docker-compose.traefik.yml up -d   # once, before the app stack

deploy.sh asks which situation you're in and does this for you — the command above is only needed for a fully manual setup. Set TRAEFIK_ACME_EMAIL in .env if you use the bundled option; Let's Encrypt requires a contact address for certificate notices.

This means, either way:

  • A Docker network named root_proxy must exist, with a Traefik on it
  • That Traefik needs a certificate resolver named letsencrypt

Verify before deploying:

docker network ls | grep root_proxy
docker network inspect root_proxy

If the network doesn't exist and neither Traefik is ever brought up, docker compose up on the main stack fails immediately. If Traefik isn't on it, the containers start and nothing routes.


Prerequisites

  • Docker Engine and the Docker Compose plugin
  • Git
  • Either your own running Traefik on the root_proxy network, or the bundled docker-compose.traefik.yml, 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 | Domain, used for routing and ALLOWED_HOSTS. URL2 is a second, optional domain. |

SES_* is not in this list — deploy.sh warns if it's blank rather than refusing to run, matching how the app itself treats it. See the graceful-fallback note below.

If .env is missing entirely, deploy.sh no longer just fails: it prompts for a path to an existing one to copy in, or — after an explicit double-confirmation that this degrades security and disables mail, reCAPTCHA, and a real domain — generates a working one itself, with a random SECRET_KEY/DB_PASSWORD and URL=localhost. Useful for a quick local evaluation; replace it before this deployment is public.

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. deploy.sh warns about this but no longer refuses to run over it.
  • 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. deploy.sh warns if both are blank.

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

If you used one of the one-command installers above, this already ran automatically as their last step — you don't need to run it again unless you're redeploying.

deploy.sh validates every required key in .env (or helps you get one in place — see above), asks about Traefik (see above), then runs docker compose down followed by docker compose up --build -d. It halts before touching Docker if a required key is missing or still set to the literal placeholder SECRET.

Or, once .env and root_proxy are already sorted, 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 the bundled Fail2ban profile writes to (its nginx-blocklist action, installed automatically by the one-command installers). Reload Nginx after any manual change:

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 | Neither your own Traefik nor the bundled docker-compose.traefik.yml has been brought up on this host yet — see the Traefik section above. deploy.sh asks about this and creates the network for you when you say you already have one. | | 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 running with the project's baseline profile — the one-command installers do this automatically; verify with systemctl status fail2ban and fail2ban-client status
  • [ ] Host firewall allows only 80 and 443
  • [ ] Backups scheduled and a restore tested
  • [ ] reCAPTCHA keys set if the site accepts anonymous submissions