From b36cd2a754930baaa4bad7e6d5c92b5bc9c2854b Mon Sep 17 00:00:00 2001 From: bisco Date: Wed, 24 Jun 2026 08:59:35 +0200 Subject: [PATCH] feat: add nginx reverse proxy --- .codex/project.md | 1 + .env.example | 6 ++- README.md | 37 ++++++++++++------ backend/config/settings.py | 2 + docker-compose.yml | 30 ++++++++++++++- docs/adr/0002-nginx-reverse-proxy.md | 56 ++++++++++++++++++++++++++++ docs/architecture.md | 15 +++++--- docs/deployment.md | 23 ++++++++---- docs/operations.md | 15 ++++++-- docs/runbook.md | 11 +++++- docs/security.md | 6 ++- docs/testing.md | 9 +++-- frontend/astro.config.mjs | 5 +++ nginx/default.conf | 49 ++++++++++++++++++++++++ 14 files changed, 228 insertions(+), 37 deletions(-) create mode 100644 docs/adr/0002-nginx-reverse-proxy.md create mode 100644 nginx/default.conf diff --git a/.codex/project.md b/.codex/project.md index 1017bb5..2452e13 100644 --- a/.codex/project.md +++ b/.codex/project.md @@ -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 frontend npm run check docker compose run --rm frontend npm run build +docker compose run --rm nginx nginx -t docker compose config --quiet ``` diff --git a/.env.example b/.env.example index f410973..fd2cb7f 100644 --- a/.env.example +++ b/.env.example @@ -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 DJANGO_SECRET_KEY=replace-with-a-long-random-development-key DJANGO_DEBUG=true -DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend -WAGTAILADMIN_BASE_URL=http://localhost:8000 +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend,azionelab.org +WAGTAILADMIN_BASE_URL=http://azionelab.org:8080 PUBLIC_CMS_API_URL=http://backend:8000 +NGINX_BIND_ADDRESS=127.0.0.1 +NGINX_HTTP_PORT=8080 diff --git a/README.md b/README.md index 80256d8..e798bdc 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,20 @@ stores content, and Docker Compose supplies a reproducible local environment. aggregate read-only API at `GET /api/site/home/`. - `frontend/`: Astro components, responsive design system, typed API adapter, and Italian fallback content for development when the CMS is unavailable. +- `nginx/`: reverse-proxy virtual host for `azionelab.org`, routing public requests to + Astro or Wagtail. - `postgres`: persistent CMS database. - Docker volumes: `postgres_data` for the database and `media_data` for uploads. -See [the architecture document](docs/architecture.md) and -[ADR-0001](docs/adr/0001-headless-wagtail-astro.md) for the rationale. +See [the architecture document](docs/architecture.md), +[ADR-0001](docs/adr/0001-headless-wagtail-astro.md), and +[ADR-0002](docs/adr/0002-nginx-reverse-proxy.md) for the rationale. ## Prerequisites - 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. @@ -32,15 +36,24 @@ docker compose ps 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: -- public site: -- Wagtail admin: -- aggregate API: +- public site: +- Wagtail admin: +- aggregate API: + +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. 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 @@ -48,7 +61,7 @@ starting Gunicorn. Wait until `postgres`, `backend`, and `frontend` report healt docker compose exec backend python manage.py createsuperuser ``` -Sign in to Wagtail at . The public site has no user +Sign in to Wagtail at . The public site has no user accounts or authentication. ## 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 `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/`. ## Useful commands ```bash # Follow application logs -docker compose logs -f postgres backend frontend +docker compose logs -f postgres backend frontend nginx # Run database migrations 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 frontend npm run check docker compose run --rm frontend npm run build +docker compose run --rm nginx nginx -t docker compose config --quiet # 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 ├── frontend/ # Astro public website +├── nginx/ # Reverse-proxy virtual host ├── docs/ # Architecture, operations, security, and ADRs ├── docker-compose.yml ├── .env.example @@ -134,5 +149,5 @@ cautions. Do not commit database dumps, `.env`, or uploaded media. ## Production TODOs This repository deliberately targets a simple, working local deployment. Before a -public production launch, add 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. diff --git a/backend/config/settings.py b/backend/config/settings.py index d3e1e2c..a96d22c 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -24,6 +24,8 @@ ALLOWED_HOSTS = [ for host in os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if host.strip() ] +if "azionelab.org" not in ALLOWED_HOSTS: + ALLOWED_HOSTS.append("azionelab.org") INSTALLED_APPS = [ "home", diff --git a/docker-compose.yml b/docker-compose.yml index d97ee6a..85c0007 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,8 +27,8 @@ services: 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_DEBUG: ${DJANGO_DEBUG:-true} - DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend} - WAGTAILADMIN_BASE_URL: ${WAGTAILADMIN_BASE_URL:-http://localhost:8000} + DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend,azionelab.org} + WAGTAILADMIN_BASE_URL: ${WAGTAILADMIN_BASE_URL:-http://azionelab.org:8080} volumes: - media_data:/app/media ports: @@ -72,6 +72,32 @@ services: security_opt: - 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: postgres_data: media_data: diff --git a/docs/adr/0002-nginx-reverse-proxy.md b/docs/adr/0002-nginx-reverse-proxy.md new file mode 100644 index 0000000..95e1c03 --- /dev/null +++ b/docs/adr/0002-nginx-reverse-proxy.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 170a276..16c43c5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,11 +1,15 @@ # 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. -2. Wagtail/Django manages editorial content, serves media in development, and exposes +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. -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`, `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. 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). diff --git a/docs/deployment.md b/docs/deployment.md index f7ca18d..2eb8273 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -14,22 +14,29 @@ docker compose exec backend python manage.py seed_demo The backend applies migrations and collects static files before starting Gunicorn. 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 -port `8000`. PostgreSQL is available only on the Compose network. `postgres_data` and -`media_data` are persistent named volumes. +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. -The stack uses explicit PostgreSQL 16.9, Python 3.12.12, and Node.js 22.20 image -versions. Containers are not privileged and use `no-new-privileges`. +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`, and `PUBLIC_CMS_API_URL`. PostgreSQL -bootstrap variables are also documented in `.env.example`. Do not use the example -credentials outside local development. +`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. `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 `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 The Compose stack is a local development deployment. A public environment still needs diff --git a/docs/operations.md b/docs/operations.md index 79a4102..dc8d2f2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -5,13 +5,19 @@ ```bash docker compose up --build -d docker compose ps -docker compose logs -f backend frontend postgres +docker compose logs -f nginx backend frontend postgres docker compose down ``` -Compose health checks PostgreSQL and Django. The public health endpoint is -`http://localhost:8000/health/`; the aggregate content endpoint is -`http://localhost:8000/api/site/home/`. +Compose health checks all services. Through the virtual host, the public health endpoint +is `http://azionelab.org:8080/health/`; the aggregate content endpoint is +`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`. 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. - 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. diff --git a/docs/runbook.md b/docs/runbook.md index 07e3bee..5197f5f 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -3,7 +3,7 @@ ## Site shows fallback content 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`. 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. @@ -29,6 +29,15 @@ 4. For the frontend, request port `4321`, then check the API separately because 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 Revert the application commit and rebuild containers. Preserve database/media volumes. diff --git a/docs/security.md b/docs/security.md index 00c3eec..bff3352 100644 --- a/docs/security.md +++ b/docs/security.md @@ -4,7 +4,8 @@ and password hashing. The public site does not add accounts or write endpoints. - The aggregate API is intentionally unauthenticated and returns published editorial 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, Docker socket, or added capabilities are used. `no-new-privileges` is enabled and the application images run as non-root users. @@ -18,6 +19,9 @@ 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. Manual production hardening remains required for reverse proxy headers, TLS, media storage, backup retention, monitoring, and admin network policy. diff --git a/docs/testing.md b/docs/testing.md index 91f95d9..380e277 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,6 +8,7 @@ All tests should run inside Docker containers. 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 nginx nginx -t docker compose config --quiet ``` @@ -18,11 +19,13 @@ The test suite covers: - Django model and API integration tests; - Astro static type and template validation; - production frontend build; +- NGINX syntax and upstream configuration validation; - Docker Compose configuration validation. All commands run in containers. The backend test container starts PostgreSQL through 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 -keyboard navigation, responsive layout, and accessible names require a manual browser -pass until browser automation is added. +After seeding, smoke-test `/health/`, `/api/site/home/`, and the public page through +NGINX using the `azionelab.org` host. Also verify that an unknown host returns 404. +Basic keyboard navigation, responsive layout, and accessible names require a manual +browser pass until browser automation is added. diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs index 0ffa593..4b18864 100644 --- a/frontend/astro.config.mjs +++ b/frontend/astro.config.mjs @@ -6,4 +6,9 @@ export default defineConfig({ host: true, port: 4321, }, + vite: { + server: { + allowedHosts: ["azionelab.org"], + }, + }, }); diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..b5d9d2c --- /dev/null +++ b/nginx/default.conf @@ -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; + } +}