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
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
- 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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# 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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
"" 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 _;
|
||||||
|
|
||||||
|
location = /nginx-health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
return 404;
|
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;
|
||||||
|
|||||||
@@ -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("/");
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user