feat: add optional letsencrypt tls

This commit is contained in:
bisco
2026-06-24 11:06:33 +02:00
parent 1e3685bab8
commit 719cdce9c1
19 changed files with 561 additions and 62 deletions
+8
View File
@@ -10,3 +10,11 @@ WAGTAILADMIN_BASE_URL=http://azionelab.org:8080
PUBLIC_CMS_API_URL=http://backend:8000 PUBLIC_CMS_API_URL=http://backend:8000
NGINX_BIND_ADDRESS=127.0.0.1 NGINX_BIND_ADDRESS=127.0.0.1
NGINX_HTTP_PORT=8080 NGINX_HTTP_PORT=8080
NGINX_HTTPS_PORT=8443
LETSENCRYPT_ENABLED=0
LETSENCRYPT_DOMAIN=azionelab.org
LETSENCRYPT_EMAIL=operator@example.org
LETSENCRYPT_STAGING=1
LETSENCRYPT_RENEW_INTERVAL_SECONDS=43200
LETSENCRYPT_RETRY_SECONDS=300
TLS_RELOAD_INTERVAL_SECONDS=30
+40 -10
View File
@@ -10,19 +10,21 @@ stores content, and Docker Compose supplies a reproducible local environment.
aggregate read-only API at `GET /api/site/home/`. aggregate read-only API at `GET /api/site/home/`.
- `frontend/`: Astro components, responsive design system, typed API adapter, and - `frontend/`: Astro components, responsive design system, typed API adapter, and
Italian fallback content for development when the CMS is unavailable. Italian fallback content for development when the CMS is unavailable.
- `nginx/`: reverse-proxy virtual host for `azionelab.org`, routing public requests to - `nginx/`: reverse proxy for `azionelab.org`, with optional HTTPS termination and
Astro or Wagtail. automatic reload after certificate renewal.
- `certbot/`: optional Let's Encrypt HTTP-01 issue/renew loop for direct deployments.
- `db`: persistent PostgreSQL database. - `db`: persistent PostgreSQL database.
- Docker volumes: `postgres_data` for the database and `media_data` for uploads. - Docker volumes persist PostgreSQL, media, ACME challenges, and certificates.
See [the architecture document](docs/architecture.md), See [the architecture document](docs/architecture.md),
[ADR-0001](docs/adr/0001-headless-wagtail-astro.md), and [ADR-0001](docs/adr/0001-headless-wagtail-astro.md), and
[ADR-0002](docs/adr/0002-nginx-reverse-proxy.md) for the rationale. [ADR-0002](docs/adr/0002-nginx-reverse-proxy.md), and
[ADR-0003](docs/adr/0003-optional-letsencrypt.md) for the rationale.
## Prerequisites ## Prerequisites
- Docker Engine with Docker Compose v2. - Docker Engine with Docker Compose v2.
- Ports `8080`, `4321`, and `8000` available on localhost. - Ports `8080`, `8443`, `4321`, and `8000` available on localhost.
- A local hosts-file entry mapping `azionelab.org` to `127.0.0.1`. - A local hosts-file entry mapping `azionelab.org` to `127.0.0.1`.
No host Python or Node.js installation is required. No host Python or Node.js installation is required.
@@ -53,7 +55,32 @@ Direct loopback ports `4321` and `8000` remain available for local diagnostics o
The frontend remains usable with curated fallback content if the API is unavailable. The frontend remains usable with curated fallback content if the API is unavailable.
On first startup the backend applies migrations and collects static files before On first startup the backend applies migrations and collects static files before
starting Gunicorn. Wait until all four services report healthy. starting Gunicorn. Wait until the four default services report healthy. The optional
`certbot` service has zero replicas while `LETSENCRYPT_ENABLED=0`.
## Optional HTTPS with Let's Encrypt
Keep `LETSENCRYPT_ENABLED=0` when a load balancer terminates TLS. For direct public
exposure, first point the domain DNS records to the host and make TCP ports 80 and 443
reachable, then set:
```dotenv
LETSENCRYPT_ENABLED=1
LETSENCRYPT_DOMAIN=azionelab.org
LETSENCRYPT_EMAIL=operator@example.org
LETSENCRYPT_STAGING=1
NGINX_BIND_ADDRESS=0.0.0.0
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443
WAGTAILADMIN_BASE_URL=https://azionelab.org
DJANGO_DEBUG=false
```
Start with the staging CA to validate DNS and firewall configuration without consuming
production rate limits. Then change `LETSENCRYPT_STAGING=0` and remove the staging
certificate volume before requesting the trusted certificate; staging certificates
cannot be converted in place. See [deployment](docs/deployment.md) for the exact reset
and production commands.
## Create an editor account ## Create an editor account
@@ -99,7 +126,7 @@ working during the transition.
```bash ```bash
# Follow application logs # Follow application logs
docker compose logs -f db backend frontend proxy docker compose logs -f db backend frontend proxy certbot
# Run database migrations # Run database migrations
docker compose exec backend python manage.py migrate docker compose exec backend python manage.py migrate
@@ -112,6 +139,7 @@ docker compose run --rm proxy nginx -t
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests
docker compose config --quiet docker compose config --quiet
LETSENCRYPT_ENABLED=1 docker compose config --quiet
# Stop the stack without deleting content # Stop the stack without deleting content
docker compose down docker compose down
@@ -128,8 +156,9 @@ local environment; run the seed command again after the next startup.
```text ```text
. .
├── backend/ # Wagtail/Django CMS and API ├── backend/ # Wagtail/Django CMS and API
├── certbot/ # Optional Let's Encrypt renewal loop
├── frontend/ # Astro public website ├── frontend/ # Astro public website
├── nginx/ # Reverse-proxy virtual host ├── nginx/ # Reverse proxy and dynamic TLS configuration
├── tests/functional/ # Playwright browser tests ├── tests/functional/ # Playwright browser tests
├── docker-compose.test.yml # Functional-test Compose override ├── docker-compose.test.yml # Functional-test Compose override
├── docs/ # Architecture, operations, security, and ADRs ├── docs/ # Architecture, operations, security, and ADRs
@@ -173,5 +202,6 @@ cautions. Do not commit database dumps, `.env`, or uploaded media.
## Production TODOs ## Production TODOs
This repository deliberately targets a simple, working local deployment. Before a This repository deliberately targets a simple, working local deployment. Before a
public production launch, add TLS certificates, production-grade static/media public production launch, configure either the optional direct TLS mode or TLS at the
serving, off-host backups, monitoring, and a deployment-specific secret manager. load balancer, plus production-grade static/media serving, off-host backups,
monitoring, and a deployment-specific secret manager.
+1
View File
@@ -118,6 +118,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
WAGTAIL_SITE_NAME = "Azione!Lab" WAGTAIL_SITE_NAME = "Azione!Lab"
WAGTAILADMIN_BASE_URL = os.getenv("WAGTAILADMIN_BASE_URL", "http://localhost:8000") WAGTAILADMIN_BASE_URL = os.getenv("WAGTAILADMIN_BASE_URL", "http://localhost:8000")
WAGTAIL_I18N_ENABLED = False WAGTAIL_I18N_ENABLED = False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
+77
View File
@@ -0,0 +1,77 @@
#!/bin/sh
set -eu
domain="${LETSENCRYPT_DOMAIN:-azionelab.org}"
email="${LETSENCRYPT_EMAIL:-}"
staging="${LETSENCRYPT_STAGING:-0}"
renew_interval="${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200}"
retry_interval="${LETSENCRYPT_RETRY_SECONDS:-300}"
case "$domain" in
"" | *[!A-Za-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*)
echo "Invalid LETSENCRYPT_DOMAIN: $domain" >&2
exit 1
;;
esac
case "$domain" in
*.*) ;;
*)
echo "LETSENCRYPT_DOMAIN must be a fully qualified domain name." >&2
exit 1
;;
esac
if [ -z "$email" ]; then
echo "LETSENCRYPT_EMAIL is required when Let's Encrypt is enabled." >&2
exit 1
fi
case "$staging" in
0 | 1) ;;
*)
echo "LETSENCRYPT_STAGING must be 0 or 1." >&2
exit 1
;;
esac
case "$renew_interval:$retry_interval" in
*[!0-9:]* | :* | *:)
echo "Certbot intervals must be positive integers." >&2
exit 1
;;
esac
if [ "$renew_interval" -eq 0 ] || [ "$retry_interval" -eq 0 ]; then
echo "Certbot intervals must be positive integers." >&2
exit 1
fi
staging_argument=""
if [ "$staging" = "1" ]; then
staging_argument="--staging"
fi
trap 'exit 0' TERM INT
while :; do
if certbot certonly \
--webroot \
--webroot-path /var/www/certbot \
--preferred-challenges http \
--cert-name "$domain" \
--domain "$domain" \
--email "$email" \
--agree-tos \
--non-interactive \
--keep-until-expiring \
$staging_argument; then
delay="$renew_interval"
else
echo "Certificate request failed; retrying after ${retry_interval}s." >&2
delay="$retry_interval"
fi
sleep "$delay" &
wait $! || true
done
+40 -3
View File
@@ -77,13 +77,20 @@ services:
- no-new-privileges:true - no-new-privileges:true
proxy: proxy:
image: nginx:1.30.0-alpine build:
context: ./nginx
restart: unless-stopped restart: unless-stopped
init: true init: true
environment:
LETSENCRYPT_ENABLED: ${LETSENCRYPT_ENABLED:-0}
LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org}
TLS_RELOAD_INTERVAL_SECONDS: ${TLS_RELOAD_INTERVAL_SECONDS:-30}
volumes: volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - letsencrypt_data:/etc/letsencrypt:ro
- certbot_challenges:/var/www/certbot:ro
ports: ports:
- "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTP_PORT:-8080}:80" - "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTP_PORT:-8080}:80"
- "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTPS_PORT:-8443}:443"
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
@@ -93,7 +100,7 @@ services:
test: test:
[ [
"CMD-SHELL", "CMD-SHELL",
"wget -q -O /dev/null --header='Host: azionelab.org' http://127.0.0.1/health/ || exit 1", "wget -q -O /dev/null http://127.0.0.1/nginx-health || exit 1",
] ]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
@@ -102,6 +109,36 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
certbot:
image: certbot/certbot:v5.6.0
restart: unless-stopped
init: true
read_only: true
environment:
LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org}
LETSENCRYPT_EMAIL: "${LETSENCRYPT_EMAIL:-}"
LETSENCRYPT_STAGING: ${LETSENCRYPT_STAGING:-1}
LETSENCRYPT_RENEW_INTERVAL_SECONDS: ${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200}
LETSENCRYPT_RETRY_SECONDS: ${LETSENCRYPT_RETRY_SECONDS:-300}
entrypoint: ["/bin/sh", "/opt/certbot/renew.sh"]
volumes:
- ./certbot/renew.sh:/opt/certbot/renew.sh:ro
- letsencrypt_data:/etc/letsencrypt
- certbot_challenges:/var/www/certbot
tmpfs:
- /tmp
- /var/lib/letsencrypt
- /var/log/letsencrypt
depends_on:
proxy:
condition: service_healthy
deploy:
replicas: ${LETSENCRYPT_ENABLED:-0}
security_opt:
- no-new-privileges:true
volumes: volumes:
postgres_data: postgres_data:
media_data: media_data:
letsencrypt_data:
certbot_challenges:
+5 -2
View File
@@ -4,6 +4,9 @@ Date: 2026-06-24
Status: Accepted Status: Accepted
TLS aspects of this decision are extended by
[ADR-0003](0003-optional-letsencrypt.md).
## Context ## Context
The Compose stack exposes Astro and Wagtail on separate local ports. Azione!Lab needs The Compose stack exposes Astro and Wagtail on separate local ports. Azione!Lab needs
@@ -35,8 +38,8 @@ TLS termination is outside this local implementation.
share the public domain. share the public domain.
- Replacing Astro or Django with static files served directly by NGINX was rejected as - Replacing Astro or Django with static files served directly by NGINX was rejected as
unnecessary for the requested local stack. unnecessary for the requested local stack.
- Adding automatic certificates was deferred because DNS and certificate ownership are - Adding automatic certificates was initially deferred because DNS and certificate
not part of this task. ownership were not part of this task; ADR-0003 later adds an opt-in implementation.
## Security impact ## Security impact
+71
View File
@@ -0,0 +1,71 @@
# ADR-0003: Optional Let's Encrypt termination for direct deployments
Date: 2026-06-24
Status: Accepted
## Context
Azione!Lab may be deployed either behind a load balancer that already terminates TLS or
directly on a public host. Running an ACME client unconditionally would duplicate edge
responsibilities in the first topology, while direct exposure still needs automated
certificate issuance and renewal.
## Decision
Add an opt-in Certbot service controlled by `LETSENCRYPT_ENABLED`. Docker Compose gives
that service zero replicas by default and one replica only when the value is `1`.
Certbot uses the HTTP-01 webroot method and shares separate challenge and certificate
volumes with NGINX.
NGINX always serves the ACME challenge path over HTTP. When TLS is enabled but no
certificate exists, application traffic remains available over HTTP. An entrypoint
watcher detects certificate creation or renewal, renders the HTTPS virtual host,
validates the configuration, reloads NGINX, and redirects non-ACME HTTP traffic to
HTTPS. No Docker socket or container-control privilege is required.
The direct-deployment operator is responsible for public DNS, inbound ports 80 and 443,
a valid contact email, and selecting the staging or production ACME endpoint. Behind a
load balancer, Certbot stays disabled and the load balancer owns certificates.
## Consequences
- One Compose definition supports both deployment topologies.
- Direct deployments gain automated issue and renewal without manual certificate copy.
- The first direct request can use HTTP until issuance completes; operators must not
publish sensitive workflows before TLS has been verified.
- Certificate and challenge volumes add persistent operational state.
- HTTP-01 cannot issue when port 80 or DNS is controlled by another edge.
## Alternatives considered
- Always running Certbot was rejected because it conflicts with load-balancer-managed
certificates and creates unnecessary ACME traffic.
- Separate Compose files per topology were rejected as avoidable configuration drift
for this small stack.
- Giving Certbot access to the Docker socket to reload NGINX was rejected because that
privilege is disproportionate; NGINX can safely watch its read-only certificate
volume.
- DNS-01 was not selected because it requires provider-specific credentials and
dependencies. It remains a future option for wildcard certificates or closed port 80.
## Security impact
The certificate private key is writable only by Certbot and read-only to NGINX. Certbot
uses a read-only root filesystem, temporary runtime mounts, `no-new-privileges`, and no
Docker socket. Production must restrict application ports and the proxy trust boundary;
Django accepts the secure forwarded-protocol header for load-balancer deployments.
## Operational impact
Operators must monitor both Certbot issue/renew logs and NGINX reload logs, protect the
certificate volume, and test DNS/firewall changes with the staging CA. Renewals are
checked every 12 hours and NGINX detects certificate changes every 30 seconds by
default. These intervals are configurable.
## Rollback
Set `LETSENCRYPT_ENABLED=0` and recreate the stack to stop Certbot while retaining
certificate state. Terminate TLS at a load balancer if public service must continue.
The certificate volumes can be removed only after confirming they are no longer needed;
database and media volumes are independent.
+9 -2
View File
@@ -1,15 +1,21 @@
# Architecture # Architecture
The system has four runtime components on the Docker Compose default network: The system has four default runtime components and one optional component on the Docker
Compose network:
1. NGINX accepts requests for `azionelab.org` and routes them by path. 1. NGINX accepts requests for `azionelab.org` and routes them by path.
2. Astro renders the public single page and requests one aggregate JSON document. 2. Astro renders the public single page and requests one aggregate JSON document.
3. Wagtail/Django manages editorial content, serves media in development, and exposes 3. Wagtail/Django manages editorial content, serves media in development, and exposes
the read-only `/api/site/home/` endpoint. the read-only `/api/site/home/` endpoint.
4. PostgreSQL persists Wagtail content and metadata. 4. PostgreSQL persists Wagtail content and metadata.
5. When explicitly enabled, Certbot obtains and renews a Let's Encrypt certificate by
writing HTTP-01 challenges and certificate files to shared named volumes.
NGINX sends `/admin`, `/api`, `/documents`, `/health`, `/media`, and `/static` paths to NGINX sends `/admin`, `/api`, `/documents`, `/health`, `/media`, and `/static` paths to
Wagtail. All remaining paths go to Astro. Unknown virtual hosts receive a 404 response. Wagtail. All remaining paths go to Astro. Unknown virtual hosts receive a 404 response.
The ACME challenge path is served directly from its shared volume. If automatic TLS is
enabled, NGINX starts in HTTP mode, detects the first certificate, adds its HTTPS
virtual host, redirects application traffic to HTTPS, and reloads after renewals.
The aggregate response contains `settings`, `homepage`, `feature_cards`, `teacher`, The aggregate response contains `settings`, `homepage`, `feature_cards`, `teacher`,
`lesson_info`, `shows`, and `gallery_items`. Image values contain browser-facing URLs `lesson_info`, `shows`, and `gallery_items`. Image values contain browser-facing URLs
@@ -23,4 +29,5 @@ Compose volume.
The deployment topology and content model are recorded in The deployment topology and content model are recorded in
[ADR-0001](adr/0001-headless-wagtail-astro.md) and [ADR-0001](adr/0001-headless-wagtail-astro.md) and
[ADR-0002](adr/0002-nginx-reverse-proxy.md). [ADR-0002](adr/0002-nginx-reverse-proxy.md). Optional TLS termination is recorded in
[ADR-0003](adr/0003-optional-letsencrypt.md).
+54 -13
View File
@@ -18,16 +18,17 @@ Add `127.0.0.1 azionelab.org` to the local hosts file, then use
`http://azionelab.org:8080`. NGINX binds to loopback port `8080` by default and routes `http://azionelab.org:8080`. NGINX binds to loopback port `8080` by default and routes
the domain to Astro or Wagtail. Their direct loopback ports `4321` and `8000` remain the domain to Astro or Wagtail. Their direct loopback ports `4321` and `8000` remain
available for diagnostics. PostgreSQL is available only on the Compose network. available for diagnostics. PostgreSQL is available only on the Compose network.
`postgres_data` and `media_data` are persistent named volumes. `postgres_data` and `media_data` are persistent named volumes. Local HTTPS is disabled
by default; `letsencrypt_data` and `certbot_challenges` remain empty unless used.
The stack uses explicit PostgreSQL 16.9, Python 3.12.12, Node.js 22.20, and NGINX 1.30.0 The stack uses explicit PostgreSQL 16.9, Python 3.12.12, Node.js 22.20, and NGINX 1.30.0
image versions. Containers are not privileged and use `no-new-privileges`. image versions. Containers are not privileged and use `no-new-privileges`.
Required runtime variables are `DATABASE_URL`, `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`, Required runtime variables are `DATABASE_URL`, `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`,
`DJANGO_ALLOWED_HOSTS`, `WAGTAILADMIN_BASE_URL`, `PUBLIC_CMS_API_URL`, `DJANGO_ALLOWED_HOSTS`, `WAGTAILADMIN_BASE_URL`, `PUBLIC_CMS_API_URL`,
`NGINX_BIND_ADDRESS`, and `NGINX_HTTP_PORT`. PostgreSQL bootstrap variables are also `NGINX_BIND_ADDRESS`, `NGINX_HTTP_PORT`, and `NGINX_HTTPS_PORT`. The optional certificate
documented in `.env.example`. Do not use the example credentials outside local variables and PostgreSQL bootstrap variables are documented in `.env.example`. Do not
development. use the example credentials outside local development.
`WAGTAILADMIN_BASE_URL` must be browser-reachable because it is used for media URLs. `WAGTAILADMIN_BASE_URL` must be browser-reachable because it is used for media URLs.
`PUBLIC_CMS_API_URL` must be reachable by Astro; within Compose it is `PUBLIC_CMS_API_URL` must be reachable by Astro; within Compose it is
@@ -37,18 +38,58 @@ The PostgreSQL Compose service is `db`, so new `DATABASE_URL` values use `db:543
The internal `postgres` alias is retained only for compatibility with existing local The internal `postgres` alias is retained only for compatibility with existing local
`.env` files and should not be used in new configuration. `.env` files and should not be used in new configuration.
The local virtual host is HTTP-only. Production must publish NGINX through the intended ## TLS deployment modes
network interface, configure DNS for `azionelab.org`, terminate TLS, and prevent direct
external access to application ports. When an external load balancer terminates TLS, leave `LETSENCRYPT_ENABLED=0`. The
`certbot` service then has zero replicas and NGINX serves HTTP to the trusted internal
network. Configure the load balancer to set `X-Forwarded-Proto: https`, keep application
ports private, and restrict proxy access to the load balancer network.
For direct exposure, the HTTP-01 challenge requires public DNS for
`LETSENCRYPT_DOMAIN` to resolve to this host and inbound TCP ports 80 and 443 to reach
NGINX. Use a real operator address and production-safe Django settings:
```dotenv
LETSENCRYPT_ENABLED=1
LETSENCRYPT_DOMAIN=azionelab.org
LETSENCRYPT_EMAIL=operator@example.org
LETSENCRYPT_STAGING=1
NGINX_BIND_ADDRESS=0.0.0.0
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443
WAGTAILADMIN_BASE_URL=https://azionelab.org
DJANGO_DEBUG=false
DJANGO_ALLOWED_HOSTS=azionelab.org
```
Then run `docker compose up --build -d` and inspect `docker compose logs certbot proxy`.
NGINX serves HTTP until a certificate exists, then reloads and redirects normal HTTP
requests to HTTPS. The ACME path remains available over HTTP for renewal.
Use the Let's Encrypt staging CA first. Before switching to the production CA, stop the
stack and remove only the staging certificate volume after checking its exact Compose
project name:
```bash
docker compose down
docker volume ls --filter label=com.docker.compose.volume=letsencrypt_data
docker volume rm PROJECT_letsencrypt_data
```
Set `LETSENCRYPT_STAGING=0`, restart, and verify the certificate issuer in a browser or
TLS inspection tool. Never use `docker compose down --volumes` on an environment whose
database, media, or certificates must be retained.
## Production boundary ## Production boundary
The Compose stack is a local development deployment. A public environment still needs The Compose stack remains a minimal deployment base. A public environment still needs
TLS termination, production static/media serving, restricted admin access, managed production static/media serving, restricted admin access, managed secrets, off-host
secrets, off-host backups, monitoring, and an explicit domain/allowed-host policy. backups, monitoring, firewall rules, and an explicit domain/allowed-host policy.
## Rollback ## Rollback
Revert the application commit and rebuild images. Keep the database and media volumes Set `LETSENCRYPT_ENABLED=0` to disable the certificate service without deleting
unless content deletion is intentional. Schema rollback must be evaluated per Django certificates, or terminate TLS at the load balancer. Revert the application commit and
migration; take coordinated database and media backups first. rebuild images for a full rollback. Keep database, media, and certificate volumes unless
deletion is intentional. Schema rollback must be evaluated per Django migration; take
coordinated database and media backups first.
+25 -3
View File
@@ -23,8 +23,27 @@ Apply schema changes with `docker compose exec backend python manage.py migrate`
Create editors with `createsuperuser`; use Wagtail permissions for later non-superuser Create editors with `createsuperuser`; use Wagtail permissions for later non-superuser
accounts. accounts.
The backend applies migrations at container startup. To permanently reset local The backend applies migrations at container startup. To permanently reset local state,
state, use `docker compose down --volumes`; this deletes both database and media. use `docker compose down --volumes`; this deletes database, media, ACME challenges, and
certificate state.
## Certificate operations
With `LETSENCRYPT_ENABLED=1`, Certbot checks the certificate every 12 hours by default
and renews it when due. NGINX checks the read-only certificate volume every 30 seconds
and reloads only after its configuration validates. Adjust these intervals only for a
documented operational reason.
```bash
docker compose ps -a certbot proxy
docker compose logs --tail=200 certbot proxy
docker compose run --rm --no-deps --entrypoint certbot certbot certificates
```
Certificate state is stored in `letsencrypt_data`; include it in protected host backups
if recovery must preserve the same private key. Never copy its content into the
repository or general application logs. When TLS is terminated by a load balancer,
keep the service disabled and manage certificates at that edge instead.
## Backup and restore ## Backup and restore
@@ -48,4 +67,7 @@ Keep real backups encrypted and off-host with a defined retention policy.
- Frontend fallback content can mask a CMS outage, so monitor backend health directly. - Frontend fallback content can mask a CMS outage, so monitor backend health directly.
- Local named volumes are not off-host backups. - Local named volumes are not off-host backups.
- Development media serving is unsuitable for production traffic. - Development media serving is unsuitable for production traffic.
- The local reverse proxy provides HTTP only; it does not manage certificates. - Automatic issuance depends on public DNS, inbound port 80, Let's Encrypt
availability, and its rate limits. Test with the staging CA first.
- Deleting `letsencrypt_data` loses certificate account/key state and triggers a new
issuance attempt when the service is enabled again.
+28 -2
View File
@@ -38,7 +38,33 @@
4. Inspect `docker compose logs --tail=200 proxy backend frontend`. 4. Inspect `docker compose logs --tail=200 proxy backend frontend`.
5. Verify local DNS or `/etc/hosts` maps `azionelab.org` to the proxy address. 5. Verify local DNS or `/etc/hosts` maps `azionelab.org` to the proxy address.
## Let's Encrypt does not issue a certificate
1. Confirm `LETSENCRYPT_ENABLED=1` and that `docker compose ps -a certbot proxy` shows
both containers running and the proxy healthy.
2. Check that the domain's public A/AAAA records point to this host. Remove an AAAA
record if IPv6 does not actually reach it.
3. Verify inbound TCP port 80 reaches NGINX; HTTP-01 cannot use only port 443.
4. Request `http://azionelab.org/.well-known/acme-challenge/missing`: a 404 from NGINX
confirms the challenge route is reachable, while a timeout or another server does
not.
5. Inspect `docker compose logs --tail=200 certbot proxy` for ACME validation or rate
limit errors. Do not repeatedly retry the production CA; use staging while fixing
connectivity.
## HTTPS is not activated after issuance
1. Run `docker compose run --rm --no-deps --entrypoint certbot certbot certificates`.
2. Confirm `LETSENCRYPT_DOMAIN` exactly matches the certificate name used by both
services.
3. Wait for `TLS_RELOAD_INTERVAL_SECONDS`, then inspect proxy logs for `nginx -t` or
reload errors.
4. Run `docker compose exec proxy nginx -t` and check HTTPS locally with an explicit
DNS override.
## Rollback ## Rollback
Revert the application commit and rebuild containers. Preserve database/media volumes. Disable Certbot with `LETSENCRYPT_ENABLED=0` if TLS is moving to a load balancer, then
Before reversing migrations or deleting volumes, make and validate coordinated backups. recreate the affected services. Revert the application commit and rebuild containers
for a full rollback. Preserve database, media, and certificate volumes. Before
reversing migrations or deleting volumes, make and validate coordinated backups.
+12 -7
View File
@@ -11,21 +11,26 @@
the application images run as non-root users. the application images run as non-root users.
- `.env` is ignored. `.env.example` contains replaceable development placeholders, - `.env` is ignored. `.env.example` contains replaceable development placeholders,
never production credentials. Use a deployment secret manager outside local use. never production credentials. Use a deployment secret manager outside local use.
- `DJANGO_DEBUG` must be false and allowed hosts explicit outside development. Add TLS - `DJANGO_DEBUG` must be false and allowed hosts explicit outside development. Public
at the edge before public exposure. traffic must use either the optional direct TLS mode or TLS at a load balancer.
- Database and uploaded media backups may contain personal data. Restrict, encrypt, - Database and uploaded media backups may contain personal data. Restrict, encrypt,
retain, and delete them according to the operator's privacy policy. retain, and delete them according to the operator's privacy policy.
- Avoid placing personal phone numbers or private contact details in logs. The API - Avoid placing personal phone numbers or private contact details in logs. The API
legitimately exposes only contact details approved for publication. legitimately exposes only contact details approved for publication.
- Dependency and image versions are explicit. Operators remain responsible for patch - Dependency and image versions are explicit. Operators remain responsible for patch
upgrades, vulnerability scans, and production digest pinning. upgrades, vulnerability scans, and production digest pinning.
- NGINX forwards the original host and standard client/protocol headers. The local - NGINX forwards the original host and standard client/protocol headers. Django trusts
configuration is HTTP-only; production must add TLS and ensure application ports are `X-Forwarded-Proto: https`; therefore direct proxy access must be limited to trusted
not externally reachable. networks when a load balancer supplies that header. The NGINX mapping accepts only
the literal `https` value as secure.
- Optional Certbot uses a pinned image, a read-only root filesystem, no Docker socket,
and only the certificate/challenge volumes. NGINX reads private keys from the
certificate volume but cannot modify them. Restrict and back up that volume as
sensitive material.
- The Playwright image is pinned and enabled only through the test Compose profile. It - The Playwright image is pinned and enabled only through the test Compose profile. It
receives no credentials, publishes no host ports, and tests only the local portal. receives no credentials, publishes no host ports, and tests only the local portal.
The override uses a separate PostgreSQL volume so its seed cannot overwrite normal The override uses a separate PostgreSQL volume so its seed cannot overwrite normal
CMS content. CMS content.
Manual production hardening remains required for reverse proxy headers, TLS, media Manual production hardening remains required for proxy trust boundaries, media
storage, backup retention, monitoring, and admin network policy. storage, backup retention, monitoring, firewalling, and admin network policy.
+3
View File
@@ -9,9 +9,11 @@ docker compose run --rm backend python manage.py test
docker compose run --rm frontend npm run check docker compose run --rm frontend npm run check
docker compose run --rm frontend npm run build docker compose run --rm frontend npm run build
docker compose run --rm proxy nginx -t docker compose run --rm proxy nginx -t
docker compose run --rm --no-deps --entrypoint certbot certbot --version
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests
docker compose config --quiet docker compose config --quiet
LETSENCRYPT_ENABLED=1 docker compose config --quiet
``` ```
## Test categories ## Test categories
@@ -22,6 +24,7 @@ The test suite covers:
- Astro static type and template validation; - Astro static type and template validation;
- production frontend build; - production frontend build;
- NGINX syntax and upstream configuration validation; - NGINX syntax and upstream configuration validation;
- Certbot image availability/version and optional Compose service rendering;
- Playwright functional browser tests through the NGINX virtual host; - Playwright functional browser tests through the NGINX virtual host;
- Docker Compose configuration validation. - Docker Compose configuration validation.
+106
View File
@@ -0,0 +1,106 @@
#!/bin/sh
set -eu
enabled="${LETSENCRYPT_ENABLED:-0}"
domain="${LETSENCRYPT_DOMAIN:-azionelab.org}"
interval="${TLS_RELOAD_INTERVAL_SECONDS:-30}"
certificate="/etc/letsencrypt/live/${domain}/fullchain.pem"
private_key="/etc/letsencrypt/live/${domain}/privkey.pem"
http_config="/etc/nginx/http-enabled/site.conf"
tls_config="/etc/nginx/tls-enabled/site.conf"
tls_template="/etc/nginx/templates/tls.conf.template.source"
base_template="/etc/nginx/templates/default.conf.template.source"
base_config="/etc/nginx/conf.d/default.conf"
proxy_routes="/etc/nginx/snippets/proxy-routes.conf"
case "$domain" in
"" | *[!A-Za-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*)
echo "Invalid LETSENCRYPT_DOMAIN: $domain" >&2
exit 1
;;
esac
case "$domain" in
*.*) ;;
*)
echo "LETSENCRYPT_DOMAIN must be a fully qualified domain name." >&2
exit 1
;;
esac
case "$enabled" in
0 | 1) ;;
*)
echo "LETSENCRYPT_ENABLED must be 0 or 1." >&2
exit 1
;;
esac
case "$interval" in
"" | *[!0-9]*)
echo "TLS_RELOAD_INTERVAL_SECONDS must be a positive integer." >&2
exit 1
;;
esac
if [ "$interval" -eq 0 ]; then
echo "TLS_RELOAD_INTERVAL_SECONDS must be a positive integer." >&2
exit 1
fi
certificate_state() {
if [ ! -s "$certificate" ] || [ ! -s "$private_key" ]; then
echo "missing"
return
fi
cksum "$certificate" "$private_key" | cksum | awk '{print $1 ":" $2}'
}
render_http_only() {
printf 'include %s;\n' "$proxy_routes" > "$http_config"
rm -f "$tls_config"
}
render_https() {
sed "s|__DOMAIN__|${domain}|g" "$tls_template" > "${tls_config}.tmp"
mv "${tls_config}.tmp" "$tls_config"
printf 'location ^~ / { return 301 https://$host$request_uri; }\n' > "$http_config"
}
sed "s|__DOMAIN__|${domain}|g" "$base_template" > "${base_config}.tmp"
mv "${base_config}.tmp" "$base_config"
if [ "$enabled" != "1" ]; then
render_http_only
exit 0
fi
state="$(certificate_state)"
if [ "$state" = "missing" ]; then
render_http_only
else
render_https
fi
(
while sleep "$interval"; do
next_state="$(certificate_state)"
if [ "$next_state" = "$state" ]; then
continue
fi
if [ "$next_state" = "missing" ]; then
render_http_only
else
render_https
fi
if nginx -t; then
nginx -s reload
state="$next_state"
else
echo "TLS configuration reload failed; retrying after ${interval}s." >&2
fi
done
) &
+10
View File
@@ -0,0 +1,10 @@
FROM nginx:1.30.0-alpine
COPY default.conf /etc/nginx/templates/default.conf.template.source
COPY proxy-routes.conf /etc/nginx/snippets/proxy-routes.conf
COPY tls.conf.template /etc/nginx/templates/tls.conf.template.source
COPY 40-configure-tls.sh /docker-entrypoint.d/40-configure-tls.sh
RUN chmod 755 /docker-entrypoint.d/40-configure-tls.sh \
&& rm -f /etc/nginx/conf.d/default.conf \
&& mkdir -p /etc/nginx/http-enabled /etc/nginx/tls-enabled /var/www/certbot
+21 -20
View File
@@ -3,6 +3,11 @@ map $http_upgrade $connection_upgrade {
"" close; "" close;
} }
map $http_x_forwarded_proto $effective_forwarded_proto {
default $scheme;
https https;
}
server_tokens off; server_tokens off;
upstream wagtail_backend { upstream wagtail_backend {
@@ -19,31 +24,27 @@ server {
listen 80 default_server; listen 80 default_server;
server_name _; server_name _;
return 404; location = /nginx-health {
access_log off;
return 200 "ok\n";
}
location / {
return 404;
}
} }
server { server {
listen 80; listen 80;
server_name azionelab.org; server_name __DOMAIN__;
client_max_body_size 10m; location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
proxy_http_version 1.1; default_type text/plain;
proxy_set_header Host $host; try_files $uri =404;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location ~ ^/(admin|api|documents|health|media|static)(/|$) {
proxy_pass http://wagtail_backend;
} }
location / { include /etc/nginx/http-enabled/site.conf;
proxy_pass http://astro_frontend;
}
} }
include /etc/nginx/tls-enabled/*.conf;
+20
View File
@@ -0,0 +1,20 @@
client_max_body_size 10m;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $effective_forwarded_proto;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location ~ ^/(admin|api|documents|health|media|static)(/|$) {
proxy_pass http://wagtail_backend;
}
location / {
proxy_pass http://astro_frontend;
}
+26
View File
@@ -0,0 +1,26 @@
server {
listen 443 ssl default_server;
http2 on;
server_name _;
ssl_certificate /etc/letsencrypt/live/__DOMAIN__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem;
ssl_reject_handshake on;
}
server {
listen 443 ssl;
http2 on;
server_name __DOMAIN__;
ssl_certificate /etc/letsencrypt/live/__DOMAIN__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=15552000" always;
include /etc/nginx/snippets/proxy-routes.conf;
}
+5
View File
@@ -152,4 +152,9 @@ test("routes Wagtail admin and rejects unknown virtual hosts", async ({
const invalidHostResponse = await invalidHostContext.get("/"); const invalidHostResponse = await invalidHostContext.get("/");
expect(invalidHostResponse.status()).toBe(404); expect(invalidHostResponse.status()).toBe(404);
await invalidHostContext.dispose(); await invalidHostContext.dispose();
const missingChallengeResponse = await page.request.get(
"/.well-known/acme-challenge/not-issued",
);
expect(missingChallengeResponse.status()).toBe(404);
}); });