generated from bisco/codex-bootstrap
feat: add optional letsencrypt tls
This commit is contained in:
@@ -10,3 +10,11 @@ WAGTAILADMIN_BASE_URL=http://azionelab.org:8080
|
||||
PUBLIC_CMS_API_URL=http://backend:8000
|
||||
NGINX_BIND_ADDRESS=127.0.0.1
|
||||
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
|
||||
|
||||
@@ -10,19 +10,21 @@ stores content, and Docker Compose supplies a reproducible local environment.
|
||||
aggregate read-only API at `GET /api/site/home/`.
|
||||
- `frontend/`: Astro components, responsive design system, typed API adapter, and
|
||||
Italian fallback content for development when the CMS is unavailable.
|
||||
- `nginx/`: reverse-proxy virtual host for `azionelab.org`, routing public requests to
|
||||
Astro or Wagtail.
|
||||
- `nginx/`: reverse proxy for `azionelab.org`, with optional HTTPS termination and
|
||||
automatic reload after certificate renewal.
|
||||
- `certbot/`: optional Let's Encrypt HTTP-01 issue/renew loop for direct deployments.
|
||||
- `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),
|
||||
[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
|
||||
|
||||
- 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`.
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -99,7 +126,7 @@ working during the transition.
|
||||
|
||||
```bash
|
||||
# Follow application logs
|
||||
docker compose logs -f db backend frontend proxy
|
||||
docker compose logs -f db backend frontend proxy certbot
|
||||
|
||||
# Run database migrations
|
||||
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 --build --rm functional-tests
|
||||
docker compose config --quiet
|
||||
LETSENCRYPT_ENABLED=1 docker compose config --quiet
|
||||
|
||||
# Stop the stack without deleting content
|
||||
docker compose down
|
||||
@@ -128,8 +156,9 @@ local environment; run the seed command again after the next startup.
|
||||
```text
|
||||
.
|
||||
├── backend/ # Wagtail/Django CMS and API
|
||||
├── certbot/ # Optional Let's Encrypt renewal loop
|
||||
├── frontend/ # Astro public website
|
||||
├── nginx/ # Reverse-proxy virtual host
|
||||
├── nginx/ # Reverse proxy and dynamic TLS configuration
|
||||
├── tests/functional/ # Playwright browser tests
|
||||
├── docker-compose.test.yml # Functional-test Compose override
|
||||
├── docs/ # Architecture, operations, security, and ADRs
|
||||
@@ -173,5 +202,6 @@ cautions. Do not commit database dumps, `.env`, or uploaded media.
|
||||
## Production TODOs
|
||||
|
||||
This repository deliberately targets a simple, working local deployment. Before a
|
||||
public production launch, add TLS certificates, production-grade static/media
|
||||
serving, off-host backups, monitoring, and a deployment-specific secret manager.
|
||||
public production launch, configure either the optional direct TLS mode or TLS at the
|
||||
load balancer, plus production-grade static/media serving, off-host backups,
|
||||
monitoring, and a deployment-specific secret manager.
|
||||
|
||||
@@ -118,6 +118,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
WAGTAIL_SITE_NAME = "Azione!Lab"
|
||||
WAGTAILADMIN_BASE_URL = os.getenv("WAGTAILADMIN_BASE_URL", "http://localhost:8000")
|
||||
WAGTAIL_I18N_ENABLED = False
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
|
||||
|
||||
@@ -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
@@ -77,13 +77,20 @@ services:
|
||||
- no-new-privileges:true
|
||||
|
||||
proxy:
|
||||
image: nginx:1.30.0-alpine
|
||||
build:
|
||||
context: ./nginx
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
environment:
|
||||
LETSENCRYPT_ENABLED: ${LETSENCRYPT_ENABLED:-0}
|
||||
LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org}
|
||||
TLS_RELOAD_INTERVAL_SECONDS: ${TLS_RELOAD_INTERVAL_SECONDS:-30}
|
||||
volumes:
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- letsencrypt_data:/etc/letsencrypt:ro
|
||||
- certbot_challenges:/var/www/certbot:ro
|
||||
ports:
|
||||
- "${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:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@@ -93,7 +100,7 @@ services:
|
||||
test:
|
||||
[
|
||||
"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
|
||||
timeout: 5s
|
||||
@@ -102,6 +109,36 @@ services:
|
||||
security_opt:
|
||||
- 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:
|
||||
postgres_data:
|
||||
media_data:
|
||||
letsencrypt_data:
|
||||
certbot_challenges:
|
||||
|
||||
@@ -4,6 +4,9 @@ Date: 2026-06-24
|
||||
|
||||
Status: Accepted
|
||||
|
||||
TLS aspects of this decision are extended by
|
||||
[ADR-0003](0003-optional-letsencrypt.md).
|
||||
|
||||
## Context
|
||||
|
||||
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.
|
||||
- Replacing Astro or Django with static files served directly by NGINX was rejected as
|
||||
unnecessary for the requested local stack.
|
||||
- Adding automatic certificates was deferred because DNS and certificate ownership are
|
||||
not part of this task.
|
||||
- Adding automatic certificates was initially deferred because DNS and certificate
|
||||
ownership were not part of this task; ADR-0003 later adds an opt-in implementation.
|
||||
|
||||
## Security impact
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,15 +1,21 @@
|
||||
# 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.
|
||||
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
|
||||
the read-only `/api/site/home/` endpoint.
|
||||
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
|
||||
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`,
|
||||
`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
|
||||
[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
@@ -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
|
||||
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.
|
||||
`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
|
||||
image versions. Containers are not privileged and use `no-new-privileges`.
|
||||
|
||||
Required runtime variables are `DATABASE_URL`, `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`,
|
||||
`DJANGO_ALLOWED_HOSTS`, `WAGTAILADMIN_BASE_URL`, `PUBLIC_CMS_API_URL`,
|
||||
`NGINX_BIND_ADDRESS`, and `NGINX_HTTP_PORT`. PostgreSQL bootstrap variables are also
|
||||
documented in `.env.example`. Do not use the example credentials outside local
|
||||
development.
|
||||
`NGINX_BIND_ADDRESS`, `NGINX_HTTP_PORT`, and `NGINX_HTTPS_PORT`. The optional certificate
|
||||
variables and PostgreSQL bootstrap variables are documented in `.env.example`. Do not
|
||||
use the example credentials outside local development.
|
||||
|
||||
`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
|
||||
@@ -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
|
||||
`.env` files and should not be used in new configuration.
|
||||
|
||||
The local virtual host is HTTP-only. Production must publish NGINX through the intended
|
||||
network interface, configure DNS for `azionelab.org`, terminate TLS, and prevent direct
|
||||
external access to application ports.
|
||||
## TLS deployment modes
|
||||
|
||||
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
|
||||
|
||||
The Compose stack is a local development deployment. A public environment still needs
|
||||
TLS termination, production static/media serving, restricted admin access, managed
|
||||
secrets, off-host backups, monitoring, and an explicit domain/allowed-host policy.
|
||||
The Compose stack remains a minimal deployment base. A public environment still needs
|
||||
production static/media serving, restricted admin access, managed secrets, off-host
|
||||
backups, monitoring, firewall rules, and an explicit domain/allowed-host policy.
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the application commit and rebuild images. Keep the database and media volumes
|
||||
unless content deletion is intentional. Schema rollback must be evaluated per Django
|
||||
migration; take coordinated database and media backups first.
|
||||
Set `LETSENCRYPT_ENABLED=0` to disable the certificate service without deleting
|
||||
certificates, or terminate TLS at the load balancer. Revert the application commit and
|
||||
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
@@ -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
|
||||
accounts.
|
||||
|
||||
The backend applies migrations at container startup. To permanently reset local
|
||||
state, use `docker compose down --volumes`; this deletes both database and media.
|
||||
The backend applies migrations at container startup. To permanently reset local state,
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- Local named volumes are not off-host backups.
|
||||
- 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
@@ -38,7 +38,33 @@
|
||||
4. Inspect `docker compose logs --tail=200 proxy backend frontend`.
|
||||
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
|
||||
|
||||
Revert the application commit and rebuild containers. Preserve database/media volumes.
|
||||
Before reversing migrations or deleting volumes, make and validate coordinated backups.
|
||||
Disable Certbot with `LETSENCRYPT_ENABLED=0` if TLS is moving to a load balancer, then
|
||||
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
@@ -11,21 +11,26 @@
|
||||
the application images run as non-root users.
|
||||
- `.env` is ignored. `.env.example` contains replaceable development placeholders,
|
||||
never production credentials. Use a deployment secret manager outside local use.
|
||||
- `DJANGO_DEBUG` must be false and allowed hosts explicit outside development. Add TLS
|
||||
at the edge before public exposure.
|
||||
- `DJANGO_DEBUG` must be false and allowed hosts explicit outside development. Public
|
||||
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,
|
||||
retain, and delete them according to the operator's privacy policy.
|
||||
- Avoid placing personal phone numbers or private contact details in logs. The API
|
||||
legitimately exposes only contact details approved for publication.
|
||||
- Dependency and image versions are explicit. Operators remain responsible for patch
|
||||
upgrades, vulnerability scans, and production digest pinning.
|
||||
- NGINX forwards the original host and standard client/protocol headers. The local
|
||||
configuration is HTTP-only; production must add TLS and ensure application ports are
|
||||
not externally reachable.
|
||||
- NGINX forwards the original host and standard client/protocol headers. Django trusts
|
||||
`X-Forwarded-Proto: https`; therefore direct proxy access must be limited to trusted
|
||||
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
|
||||
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
|
||||
CMS content.
|
||||
|
||||
Manual production hardening remains required for reverse proxy headers, TLS, media
|
||||
storage, backup retention, monitoring, and admin network policy.
|
||||
Manual production hardening remains required for proxy trust boundaries, media
|
||||
storage, backup retention, monitoring, firewalling, and admin network policy.
|
||||
|
||||
@@ -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 build
|
||||
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 --build --rm functional-tests
|
||||
docker compose config --quiet
|
||||
LETSENCRYPT_ENABLED=1 docker compose config --quiet
|
||||
```
|
||||
|
||||
## Test categories
|
||||
@@ -22,6 +24,7 @@ The test suite covers:
|
||||
- Astro static type and template validation;
|
||||
- production frontend build;
|
||||
- NGINX syntax and upstream configuration validation;
|
||||
- Certbot image availability/version and optional Compose service rendering;
|
||||
- Playwright functional browser tests through the NGINX virtual host;
|
||||
- Docker Compose configuration validation.
|
||||
|
||||
|
||||
@@ -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
|
||||
) &
|
||||
@@ -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
|
||||
+20
-19
@@ -3,6 +3,11 @@ map $http_upgrade $connection_upgrade {
|
||||
"" close;
|
||||
}
|
||||
|
||||
map $http_x_forwarded_proto $effective_forwarded_proto {
|
||||
default $scheme;
|
||||
https https;
|
||||
}
|
||||
|
||||
server_tokens off;
|
||||
|
||||
upstream wagtail_backend {
|
||||
@@ -19,31 +24,27 @@ server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
location = /nginx-health {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name azionelab.org;
|
||||
server_name __DOMAIN__;
|
||||
|
||||
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 $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 ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
default_type text/plain;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://astro_frontend;
|
||||
}
|
||||
include /etc/nginx/http-enabled/site.conf;
|
||||
}
|
||||
|
||||
include /etc/nginx/tls-enabled/*.conf;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -152,4 +152,9 @@ test("routes Wagtail admin and rejects unknown virtual hosts", async ({
|
||||
const invalidHostResponse = await invalidHostContext.get("/");
|
||||
expect(invalidHostResponse.status()).toBe(404);
|
||||
await invalidHostContext.dispose();
|
||||
|
||||
const missingChallengeResponse = await page.request.get(
|
||||
"/.well-known/acme-challenge/not-issued",
|
||||
);
|
||||
expect(missingChallengeResponse.status()).toBe(404);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user