generated from bisco/codex-bootstrap
feat: add optional letsencrypt tls
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user