feat: add nginx reverse proxy

This commit is contained in:
bisco
2026-06-24 08:59:35 +02:00
parent 8155d94fee
commit b36cd2a754
14 changed files with 228 additions and 37 deletions
+1
View File
@@ -78,6 +78,7 @@ Configure the canonical test command for this repository:
docker compose run --rm backend python manage.py test 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 nginx nginx -t
docker compose config --quiet docker compose config --quiet
``` ```
+4 -2
View File
@@ -5,6 +5,8 @@ POSTGRES_PASSWORD=replace-with-a-local-password
DATABASE_URL=postgresql://theatre_lab:replace-with-a-local-password@postgres:5432/theatre_lab DATABASE_URL=postgresql://theatre_lab:replace-with-a-local-password@postgres:5432/theatre_lab
DJANGO_SECRET_KEY=replace-with-a-long-random-development-key DJANGO_SECRET_KEY=replace-with-a-long-random-development-key
DJANGO_DEBUG=true DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend,azionelab.org
WAGTAILADMIN_BASE_URL=http://localhost:8000 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_HTTP_PORT=8080
+26 -11
View File
@@ -10,16 +10,20 @@ 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
Astro or Wagtail.
- `postgres`: persistent CMS database. - `postgres`: persistent CMS database.
- Docker volumes: `postgres_data` for the database and `media_data` for uploads. - Docker volumes: `postgres_data` for the database and `media_data` for uploads.
See [the architecture document](docs/architecture.md) and See [the architecture document](docs/architecture.md),
[ADR-0001](docs/adr/0001-headless-wagtail-astro.md) for the rationale. [ADR-0001](docs/adr/0001-headless-wagtail-astro.md), and
[ADR-0002](docs/adr/0002-nginx-reverse-proxy.md) for the rationale.
## Prerequisites ## Prerequisites
- Docker Engine with Docker Compose v2. - Docker Engine with Docker Compose v2.
- Ports `4321` and `8000` available on localhost. - Ports `8080`, `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. No host Python or Node.js installation is required.
@@ -32,15 +36,24 @@ docker compose ps
docker compose exec backend python manage.py seed_demo docker compose exec backend python manage.py seed_demo
``` ```
For local domain resolution, add this line to `/etc/hosts` using your normal system
administration workflow:
```text
127.0.0.1 azionelab.org
```
Open: Open:
- public site: <http://localhost:4321> - public site: <http://azionelab.org:8080>
- Wagtail admin: <http://localhost:8000/admin/> - Wagtail admin: <http://azionelab.org:8080/admin/>
- aggregate API: <http://localhost:8000/api/site/home/> - aggregate API: <http://azionelab.org:8080/api/site/home/>
Direct loopback ports `4321` and `8000` remain available for local diagnostics only.
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 `postgres`, `backend`, and `frontend` report healthy. starting Gunicorn. Wait until all four services report healthy.
## Create an editor account ## Create an editor account
@@ -48,7 +61,7 @@ starting Gunicorn. Wait until `postgres`, `backend`, and `frontend` report healt
docker compose exec backend python manage.py createsuperuser docker compose exec backend python manage.py createsuperuser
``` ```
Sign in to Wagtail at <http://localhost:8000/admin/>. The public site has no user Sign in to Wagtail at <http://azionelab.org:8080/admin/>. The public site has no user
accounts or authentication. accounts or authentication.
## Demo content ## Demo content
@@ -75,14 +88,14 @@ after seeding may be overwritten for records managed by the command.
The frontend reads `PUBLIC_CMS_API_URL`; inside Compose it defaults to The frontend reads `PUBLIC_CMS_API_URL`; inside Compose it defaults to
`http://backend:8000`. Media URLs use `WAGTAILADMIN_BASE_URL` so browsers receive the `http://backend:8000`. Media URLs use `WAGTAILADMIN_BASE_URL` so browsers receive the
public `http://localhost:8000` address. Change both values for another environment. public reverse-proxy address. Change both values for another environment.
The standard published-page endpoint is also available at `/api/v2/pages/`. The standard published-page endpoint is also available at `/api/v2/pages/`.
## Useful commands ## Useful commands
```bash ```bash
# Follow application logs # Follow application logs
docker compose logs -f postgres backend frontend docker compose logs -f postgres backend frontend nginx
# Run database migrations # Run database migrations
docker compose exec backend python manage.py migrate docker compose exec backend python manage.py migrate
@@ -91,6 +104,7 @@ docker compose exec backend python manage.py migrate
docker compose run --rm backend python manage.py test 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 nginx nginx -t
docker compose config --quiet docker compose config --quiet
# Stop the stack without deleting content # Stop the stack without deleting content
@@ -109,6 +123,7 @@ local environment; run the seed command again after the next startup.
. .
├── backend/ # Wagtail/Django CMS and API ├── backend/ # Wagtail/Django CMS and API
├── frontend/ # Astro public website ├── frontend/ # Astro public website
├── nginx/ # Reverse-proxy virtual host
├── docs/ # Architecture, operations, security, and ADRs ├── docs/ # Architecture, operations, security, and ADRs
├── docker-compose.yml ├── docker-compose.yml
├── .env.example ├── .env.example
@@ -134,5 +149,5 @@ 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 a TLS reverse proxy, production-grade static/media public production launch, add TLS certificates, production-grade static/media
serving, off-host backups, monitoring, and a deployment-specific secret manager. serving, off-host backups, monitoring, and a deployment-specific secret manager.
+2
View File
@@ -24,6 +24,8 @@ ALLOWED_HOSTS = [
for host in os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") for host in os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
if host.strip() if host.strip()
] ]
if "azionelab.org" not in ALLOWED_HOSTS:
ALLOWED_HOSTS.append("azionelab.org")
INSTALLED_APPS = [ INSTALLED_APPS = [
"home", "home",
+28 -2
View File
@@ -27,8 +27,8 @@ services:
DATABASE_URL: ${DATABASE_URL:-postgresql://theatre_lab:replace-with-a-local-password@postgres:5432/theatre_lab} DATABASE_URL: ${DATABASE_URL:-postgresql://theatre_lab:replace-with-a-local-password@postgres:5432/theatre_lab}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-replace-with-a-long-random-development-key} DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-replace-with-a-long-random-development-key}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true} DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend} DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend,azionelab.org}
WAGTAILADMIN_BASE_URL: ${WAGTAILADMIN_BASE_URL:-http://localhost:8000} WAGTAILADMIN_BASE_URL: ${WAGTAILADMIN_BASE_URL:-http://azionelab.org:8080}
volumes: volumes:
- media_data:/app/media - media_data:/app/media
ports: ports:
@@ -72,6 +72,32 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
nginx:
image: nginx:1.30.0-alpine
restart: unless-stopped
init: true
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTP_PORT:-8080}:80"
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
"wget -q -O /dev/null --header='Host: azionelab.org' http://127.0.0.1/health/ || exit 1",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
security_opt:
- no-new-privileges:true
volumes: volumes:
postgres_data: postgres_data:
media_data: media_data:
+56
View File
@@ -0,0 +1,56 @@
# ADR-0002: NGINX reverse proxy for the public virtual host
Date: 2026-06-24
Status: Accepted
## Context
The Compose stack exposes Astro and Wagtail on separate local ports. Azione!Lab needs
one domain-aware HTTP entry point for `azionelab.org` while keeping application routing
and service discovery inside Docker Compose.
## Decision
Add a pinned NGINX Alpine container. Its `azionelab.org` virtual host routes Wagtail
admin, API, documents, health, static files, and media to Django; all other paths go to
Astro. An explicit default virtual host returns 404 for unknown host names.
For local development NGINX binds to `127.0.0.1:8080`, configurable through
`NGINX_BIND_ADDRESS` and `NGINX_HTTP_PORT`. Existing loopback-only application ports
remain available for diagnostics. PostgreSQL remains private to the Compose network.
TLS termination is outside this local implementation.
## Consequences
- The preferred local URL becomes `http://azionelab.org:8080` after a hosts-file entry.
- Wagtail receives the original host and standard forwarded client/protocol headers.
- Media URLs must use the reverse-proxy address through `WAGTAILADMIN_BASE_URL`.
- Production deployment still requires TLS, trusted proxy policy, and removal or
firewalling of diagnostic application ports.
## Alternatives considered
- Routing only the frontend was rejected because Wagtail admin, API, and media should
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.
## Security impact
Unknown host names are rejected. NGINX adds basic content-type and referrer headers,
runs without added capabilities or privileged mode, and is bound to loopback by default.
No TLS guarantee is made by this local configuration.
## Operational impact
NGINX depends on healthy frontend and backend services and has its own health check.
Operators must configure DNS/hosts, the public Wagtail base URL, and the published port.
## Rollback
Remove the NGINX service and configuration, restore direct application URLs, and revert
the related allowed-host and public-base-URL values. Database and media data are not
changed.
+10 -5
View File
@@ -1,11 +1,15 @@
# Architecture # Architecture
The system has three runtime components on the Docker Compose default network: The system has four runtime components on the Docker Compose default network:
1. Astro renders the public single page and requests one aggregate JSON document. 1. NGINX accepts requests for `azionelab.org` and routes them by path.
2. Wagtail/Django manages editorial content, serves media in development, and exposes 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. the read-only `/api/site/home/` endpoint.
3. PostgreSQL persists Wagtail content and metadata. 4. PostgreSQL persists Wagtail content and metadata.
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 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
@@ -18,4 +22,5 @@ has no database access and no public write API. Uploaded media is stored in a de
Compose volume. 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). [ADR-0001](adr/0001-headless-wagtail-astro.md) and
[ADR-0002](adr/0002-nginx-reverse-proxy.md).
+15 -8
View File
@@ -14,22 +14,29 @@ docker compose exec backend python manage.py seed_demo
The backend applies migrations and collects static files before starting Gunicorn. The backend applies migrations and collects static files before starting Gunicorn.
All services have health checks; wait for healthy status before opening the site. All services have health checks; wait for healthy status before opening the site.
Compose exposes the Astro site on loopback port `4321` and Django/Wagtail on loopback Add `127.0.0.1 azionelab.org` to the local hosts file, then use
port `8000`. PostgreSQL is available only on the Compose network. `postgres_data` and `http://azionelab.org:8080`. NGINX binds to loopback port `8080` by default and routes
`media_data` are persistent named volumes. 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.
The stack uses explicit PostgreSQL 16.9, Python 3.12.12, and Node.js 22.20 image The stack uses explicit PostgreSQL 16.9, Python 3.12.12, Node.js 22.20, and NGINX 1.30.0
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`, and `PUBLIC_CMS_API_URL`. PostgreSQL `DJANGO_ALLOWED_HOSTS`, `WAGTAILADMIN_BASE_URL`, `PUBLIC_CMS_API_URL`,
bootstrap variables are also documented in `.env.example`. Do not use the example `NGINX_BIND_ADDRESS`, and `NGINX_HTTP_PORT`. PostgreSQL bootstrap variables are also
credentials outside local development. 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. `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
`http://backend:8000`. `http://backend:8000`.
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.
## Production boundary ## Production boundary
The Compose stack is a local development deployment. A public environment still needs The Compose stack is a local development deployment. A public environment still needs
+11 -4
View File
@@ -5,13 +5,19 @@
```bash ```bash
docker compose up --build -d docker compose up --build -d
docker compose ps docker compose ps
docker compose logs -f backend frontend postgres docker compose logs -f nginx backend frontend postgres
docker compose down docker compose down
``` ```
Compose health checks PostgreSQL and Django. The public health endpoint is Compose health checks all services. Through the virtual host, the public health endpoint
`http://localhost:8000/health/`; the aggregate content endpoint is is `http://azionelab.org:8080/health/`; the aggregate content endpoint is
`http://localhost:8000/api/site/home/`. `http://azionelab.org:8080/api/site/home/`.
Test routing without changing the hosts file:
```bash
curl --resolve azionelab.org:8080:127.0.0.1 http://azionelab.org:8080/health/
```
Apply schema changes with `docker compose exec backend python manage.py migrate`. 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
@@ -42,3 +48,4 @@ 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.
+10 -1
View File
@@ -3,7 +3,7 @@
## Site shows fallback content ## Site shows fallback content
1. Check `docker compose ps` and the backend health status. 1. Check `docker compose ps` and the backend health status.
2. Request `http://localhost:8000/api/site/home/` directly. 2. Request `http://azionelab.org:8080/api/site/home/` through NGINX.
3. Inspect `docker compose logs backend postgres`. 3. Inspect `docker compose logs backend postgres`.
4. Verify `PUBLIC_CMS_API_URL` resolves from the frontend container. 4. Verify `PUBLIC_CMS_API_URL` resolves from the frontend container.
5. Restart the frontend after recovery so its static page is rebuilt from CMS data. 5. Restart the frontend after recovery so its static page is rebuilt from CMS data.
@@ -29,6 +29,15 @@
4. For the frontend, request port `4321`, then check the API separately because 4. For the frontend, request port `4321`, then check the API separately because
fallback content can mask a CMS outage. fallback content can mask a CMS outage.
## NGINX returns 404 or 502
1. Confirm the request host is exactly `azionelab.org`; unknown hosts intentionally
receive 404.
2. Run `docker compose exec nginx nginx -t`.
3. Check `docker compose ps` and verify both backend and frontend are healthy.
4. Inspect `docker compose logs --tail=200 nginx backend frontend`.
5. Verify local DNS or `/etc/hosts` maps `azionelab.org` to the proxy address.
## Rollback ## Rollback
Revert the application commit and rebuild containers. Preserve database/media volumes. Revert the application commit and rebuild containers. Preserve database/media volumes.
+5 -1
View File
@@ -4,7 +4,8 @@
and password hashing. The public site does not add accounts or write endpoints. and password hashing. The public site does not add accounts or write endpoints.
- The aggregate API is intentionally unauthenticated and returns published editorial - The aggregate API is intentionally unauthenticated and returns published editorial
content only; secrets and unpublished drafts must never be serialized. content only; secrets and unpublished drafts must never be serialized.
- PostgreSQL has no host-published port. Web ports bind to loopback for local use. - PostgreSQL has no host-published port. NGINX and diagnostic application ports bind to
loopback for local use. Unknown NGINX virtual hosts are rejected with 404.
- Containers are unprivileged at the Compose level: no privileged mode, host network, - Containers are unprivileged at the Compose level: no privileged mode, host network,
Docker socket, or added capabilities are used. `no-new-privileges` is enabled and Docker socket, or added capabilities are used. `no-new-privileges` is enabled and
the application images run as non-root users. the application images run as non-root users.
@@ -18,6 +19,9 @@
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
configuration is HTTP-only; production must add TLS and ensure application ports are
not externally reachable.
Manual production hardening remains required for reverse proxy headers, TLS, media Manual production hardening remains required for reverse proxy headers, TLS, media
storage, backup retention, monitoring, and admin network policy. storage, backup retention, monitoring, and admin network policy.
+6 -3
View File
@@ -8,6 +8,7 @@ All tests should run inside Docker containers.
docker compose run --rm backend python manage.py test 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 nginx nginx -t
docker compose config --quiet docker compose config --quiet
``` ```
@@ -18,11 +19,13 @@ The test suite covers:
- Django model and API integration tests; - Django model and API integration tests;
- Astro static type and template validation; - Astro static type and template validation;
- production frontend build; - production frontend build;
- NGINX syntax and upstream configuration validation;
- Docker Compose configuration validation. - Docker Compose configuration validation.
All commands run in containers. The backend test container starts PostgreSQL through All commands run in containers. The backend test container starts PostgreSQL through
Compose; the frontend checks use the checked-in lockfile for reproducible installs. Compose; the frontend checks use the checked-in lockfile for reproducible installs.
After seeding, smoke-test `/health/`, `/api/site/home/`, and the public page. Basic After seeding, smoke-test `/health/`, `/api/site/home/`, and the public page through
keyboard navigation, responsive layout, and accessible names require a manual browser NGINX using the `azionelab.org` host. Also verify that an unknown host returns 404.
pass until browser automation is added. Basic keyboard navigation, responsive layout, and accessible names require a manual
browser pass until browser automation is added.
+5
View File
@@ -6,4 +6,9 @@ export default defineConfig({
host: true, host: true,
port: 4321, port: 4321,
}, },
vite: {
server: {
allowedHosts: ["azionelab.org"],
},
},
}); });
+49
View File
@@ -0,0 +1,49 @@
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
server_tokens off;
upstream wagtail_backend {
server backend:8000;
keepalive 16;
}
upstream astro_frontend {
server frontend:4321;
keepalive 16;
}
server {
listen 80 default_server;
server_name _;
return 404;
}
server {
listen 80;
server_name azionelab.org;
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 / {
proxy_pass http://astro_frontend;
}
}