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_proxymust 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_proxynetwork, or the bundleddocker-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_SENDERdisables mail. Messages print to the console instead.deploy.shwarns about this but no longer refuses to run over it. - 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.
deploy.shwarns 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 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 running with the project's baseline profile — the one-command
installers do this automatically; verify with
systemctl status fail2banandfail2ban-client status - [ ] Host firewall allows only 80 and 443
- [ ] Backups scheduled and a restore tested
- [ ] reCAPTCHA keys set if the site accepts anonymous submissions
Contents
- Deployment
- One-command install
- The Traefik prerequisite, or the bundled alternative
- 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
Last edited on Sep 21, 2026 by Stuart Anderson
Maintainers
| Editor | Last Activity |
|---|---|
| Stuart Anderson creator | Sep 21, 2026 |