Configuration

Pengin-Pi-3 is configured through environment variables, read from a .env file in the project root (or injected directly by Docker Compose). Settings are read with python-decouple, so a variable set in the real environment wins over the file.

Copy the template and edit:

cp .env.example .env

Never commit .env.


Optional by design

Most integrations are optional. The codebase runs without AWS or Google credentials — it detects what's configured at startup and falls back rather than crashing.

Three flags in settings.py drive this:

| Flag | Set when | Absent means | |---|---|---| | AWS_SES_ENABLED | SES_USERNAME_SMTP, SES_PASSWORD_SMTP, SES_SENDER all present | Mail disabled — messages print to console | | AWS_S3_ENABLED | S3_KEY, S3_SECRET, S3_BUCKET all present | Uploads go to local disk | | RECAPTCHA_ENABLED | RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY both present | Bot verification skipped, widget hidden |

Each is all-or-nothing — two of three SES keys counts as none. Every fallback prints a line at startup, so docker compose logs web tells you what's actually active.

The database falls back the same way: a non-SQLite DB_ENGINE without complete connection details drops to a local SQLite file rather than failing at request time.

This makes a bare clone runnable. It also means a production deployment missing a key degrades silently rather than erroring, which is the thing to watch for. Check the startup log.


Django core

| Variable | Default | Notes | |---|---|---| | SECRET_KEY | An insecure built-in | Always set this. See below. | | DEBUG | True | Compose overrides to False. See below. | | ALLOWED_HOSTS | 127.0.0.1,localhost | Comma-separated. Compose builds this from URL/URL2. | | CSRF_TRUSTED_ORIGINS | empty | Comma-separated, with scheme: https://example.com |

SECRET_KEY has a working default. A deployment that never sets it starts normally, signs sessions and tokens with a key published in the public repository, and gives no warning. Generate one per deployment:

python -c "import secrets; print(secrets.token_urlsafe(50))"

deploy.sh does this for you automatically if you let it generate a degraded .env when none exists — see Deployment — but for anything public, set your own.

DEBUG defaults to True in settings.py, and .env.example also ships True. The Compose file overrides it to False, so a Docker deployment is safe by default — but anything running outside Compose that copies .env.example verbatim is in debug mode. Set it explicitly.


Database

| Variable | Default | Notes | |---|---|---| | DB_ENGINE | django.db.backends.sqlite3 | Compose sets ...postgresql | | DB_NAME | — | SQLite file path, or the Postgres database | | DB_USER | — | | | DB_PASSWORD | — | | | DB_HOST | — | postgres under Compose | | DB_PORT | — | Defaults to the engine's own |

A non-SQLite engine needs DB_NAME, DB_USER, DB_PASSWORD, and DB_HOST all present. Any missing and it falls back to db.sqlite3 in the project root, with a printed warning. That's a convenience for contributors — clone, install, runserver, no infrastructure. It's also how a production instance ends up quietly running on SQLite if one variable is fat-fingered.

FERRETDB_DB

FERRETDB_DB=postgres

Not your application database. FerretDB's DocumentDB extension installs via pg_cron into exactly one database, and this names it. Your app's database (DB_NAME) is created alongside it on first boot. Leave it as postgres unless you have a specific reason. See Deployment.


Document store

| Variable | Default | Under Compose | |---|---|---| | MONGODB_URI | mongodb://ferretdb:27017/ | Credentials injected from DB_USER/DB_PASSWORD | | MONGODB_DB_NAME | dynamic_cms | Set to ${DB_NAME} |

The defaults differ from what Compose supplies — a non-Docker run points at an unauthenticated host and a differently named database. Set both explicitly if it matters which you get. See Document Store.


Cache

| Variable | Default | |---|---| | REDIS_URL | redis://127.0.0.1:6379 |

Compose sets redis://redis:6379. Redis backs the Django cache, the analytics write buffer, request logging, and rate limiting.

Rate limiting fails open. If Redis is unreachable, the check is skipped rather than blocking requests. Availability over enforcement — but a cache outage silently disables throttling.


Mail

| Variable | Default | Notes | |---|---|---| | SES_USERNAME_SMTP | — | Required for mail | | SES_PASSWORD_SMTP | — | Required for mail | | SES_SENDER | — | Required for mail; verified sender address | | SES_SENDER_NAME | empty | Display name | | SES_HOST | email-smtp.us-west-2.amazonaws.com | | | AWS_REGION | us-west-2 | |

With any of the three required values blank, EMAIL_BACKEND becomes the console backend. Mail is composed and printed, never delivered. Nothing crashes; account validation and password reset links appear in the logs instead of an inbox, which is fine for development and a silent failure in production.

The region should match your verified domain identity and DKIM records.

Two mail paths, one credential pair

Worth knowing before you debug a mail problem. The project has two sending paths that read the same variables and interpret them differently:

  • settings.EMAIL_BACKEND configures Django's SMTP backend on port 587, treating SES_USERNAME_SMTP/SES_PASSWORD_SMTP as SES SMTP credentials.
  • util/mail/ uses Boto3's send_raw_email(), passing the same two values as an IAM access key and secret.

Those are different credential types. One pair of values cannot satisfy both. The application's own transactional mail — validation, reset, subscription — goes through util/mail/, so IAM keys are what you want. Anything calling Django's send_mail() directly will fail with them.

The variable names are historical and misleading. If you're filling them in for the first time, put an IAM access key ID in SES_USERNAME_SMTP and its secret in SES_PASSWORD_SMTP.


File storage

| Variable | Default | Notes | |---|---|---| | FILE_STORAGE_BACKEND | local | local or s3 | | S3_KEY | — | Required for s3 | | S3_SECRET | — | Required for s3 | | S3_BUCKET | — | Required for s3 | | S3_LOCATION | — | Region or endpoint |

Setting s3 without complete credentials falls back to local, with a printed warning.

Local storage means you own the disk

This is the fallback most likely to bite a deployment, because nothing about it fails — it just accumulates.

With local, every upload is written to MEDIA_ROOT (./media in the project root, bind-mounted into both the web and nginx containers). That covers image and video replacements from the slug content editor, and file and image fields on dynamic content types. Files are saved under generated UUID names.

Nothing prunes it. Replace a hero image ten times and all ten files remain; the old ones are simply no longer referenced. Delete a page and its uploads stay. There is no garbage collection, no quota, and no warning as it fills.

Three things grow on disk over a deployment's life:

  • ./media — uploads, unbounded, never cleaned
  • The postgres_data volume — including history snapshots, which store a full copy of an object's fields on every tracked edit (see History and Auditing)
  • ./logs/nginx — access logs, deliberately kept on for blocked traffic so Fail2ban can see it

So: size the volume for growth, monitor free space, and rotate the Nginx logs. If the site takes meaningful uploads — a media library, a product catalog with photos, user-submitted documents — use S3 and let object storage be someone else's capacity problem. The local backend is the right default for a small site and the wrong one for an unattended large one.

MEDIA_ROOT and MEDIA_URL are hardcoded in settings.py; change them there if you need a different path.


reCAPTCHA

| Variable | Default | |---|---| | RECAPTCHA_SITE_KEY | — | | RECAPTCHA_SECRET_KEY | — |

Both blank disables verification: the widget is hidden and server-side checks pass automatically, so forms aren't permanently rejected in environments without Google keys.

The site key is injected into every template context as site_key by a context processor. Minimum score defaults to 0.5 and is set per view via recaptcha_min_score — see Mixins.

Set these on any public site accepting anonymous submissions. Disabled reCAPTCHA looks identical to working reCAPTCHA from the outside.


Security and cookies

| Variable | Default | Notes | |---|---|---| | SESSION_COOKIE_SECURE | True | HTTPS-only session cookie | | CSRF_COOKIE_SECURE | True | HTTPS-only CSRF cookie |

Secure by default. Both are configurable rather than hardcoded so a genuinely TLS-less deployment — an internal LAN or VPN-only instance with no certificate — can turn them off. Every public site leaves them True.

If local development over plain HTTP gives you mysterious login failures, this is usually why.

Hardcoded, not configurable

These live in settings.py and need a code edit to change:

  • SECURE_HSTS_SECONDS = 31536000, with INCLUDE_SUBDOMAINS and PRELOAD both on. One year, all subdomains, preload-eligible. Fine for an established domain; a commitment to think about before putting a new one behind it.
  • SESSION_COOKIE_AGE = 1800 — 30-minute sessions
  • SESSION_EXPIRE_AT_BROWSER_CLOSE = True
  • SECURE_PROXY_SSL_HEADER, USE_X_FORWARDED_HOST, USE_X_FORWARDED_PORT — required behind the Nginx and Traefik chain
  • TIME_ZONE = 'UTC', LANGUAGE_CODE = 'en-us'

Site policy

| Variable | Default | Notes | |---|---|---| | SUBSCRIPTIONS_REQUIRE_ACCOUNT | False | True requires a logged-in account to subscribe; False also accepts an anonymous email confirmed by signed link |

A site-level policy choice — the core supports both paths either way.


Docker and routing

Used by docker-compose.yml and deploy.sh rather than Django:

| Variable | Purpose | |---|---| | URL | Primary domain — Traefik routing and ALLOWED_HOSTS | | URL2 | Secondary domain (optional) | | DB_CONTAINER | Postgres container name | | WEB_CONTAINER | Web container name; also the Traefik router name, and what the bundled Fail2ban profile's nginx-container action targets (${WEB_CONTAINER}-nginx) | | REDIS_CONTAINER | Redis container name | | TRAEFIK_ACME_EMAIL | Only if using the bundled docker-compose.traefik.yml standalone reverse proxy — Let's Encrypt's contact address for certificate notices |

The Traefik rule requests a certificate covering both ${URL} and www.${URL}. If the www record doesn't exist in DNS, no certificate is issued at all — Traefik falls back to self-signed with no error outside its own logs. Create both records or drop the www clause. See Deployment.

deploy.sh's required set no longer includes mail

This used to be a gotcha: deploy.sh required all four SES variables even though the application handles them being blank perfectly well. That's fixed — deploy.sh now only hard-requires SECRET_KEY, the DB/container variables, and URL; it warns about a blank SES/reCAPTCHA setup instead of refusing to run. See Deployment for the full required-variable table and what happens if .env is missing entirely.


Minimum configurations

Local development — nothing required. Clone, install, runserver. SQLite, console mail, local storage, no reCAPTCHA. Set DEBUG=True and turn off the secure cookie flags if you're on plain HTTP.

Small production site

SECRET_KEY=<generated>
DEBUG=False
URL=example.com
URL2=
CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
DB_NAME=penginpi
DB_USER=penginpi
DB_PASSWORD=<generated>
FERRETDB_DB=postgres
DB_CONTAINER=pengin-pi-3-db
WEB_CONTAINER=pengin-pi-3-web
REDIS_CONTAINER=pengin-pi-3-redis
RECAPTCHA_SITE_KEY=<key>
RECAPTCHA_SECRET_KEY=<key>

Mail and S3 omitted — the site works, validation emails go to the log, and uploads land on disk. Add SES when you need delivery, S3 when disk becomes a concern. Add TRAEFIK_ACME_EMAIL too if you're using the bundled reverse proxy rather than your own.

Full production adds the SES and S3 blocks with FILE_STORAGE_BACKEND=s3.


Verifying

Read the startup log first — every fallback announces itself:

docker compose logs web | head -30

You are looking for the absence of these lines:

[settings] SES credentials not found - email sending is disabled
[settings] FILE_STORAGE_BACKEND=s3 but ... falling back to local storage
[settings] DB_ENGINE=... incomplete - falling back to local SQLite

Then confirm from inside the app:

docker compose exec web python manage.py shell -c \
  "from django.conf import settings; \
   print('DEBUG', settings.DEBUG); \
   print('SES', settings.AWS_SES_ENABLED); \
   print('S3', settings.AWS_S3_ENABLED); \
   print('reCAPTCHA', settings.RECAPTCHA_ENABLED); \
   print('DB', settings.DATABASES['default']['ENGINE']); \
   print('storage', settings.FILE_STORAGE_BACKEND)"

Next