Wire up the three foundation stacks (nginx-proxy, sql, portainer) in cloud/compose.yml and add real configs for the first two. nginx-proxy - Base nginx.conf with http + stream contexts, modern TLS profile, client_max_body_size baseline for gitea LFS / mlflow artifacts. - Vhosts under conf.d/: grafana, gitea, keycloak, nodered, mlflow, jupyter, portainer (HTTPS upstream), rabbitmq, jenkins. WebSocket upgrade headers where needed (grafana live, node-red editor, jupyterhub kernels, jenkins agents). - conf.d/00-default.conf serves /.well-known/acme-challenge/ on :80 and 301-redirects everything else. - stream.d/mqtt.conf terminates MQTT-TLS at 8883, proxies to rabbitmq:1883 internally. - All vhosts reference /etc/letsencrypt/live/infra/* — a stable path via certbot --cert-name infra, so the wildcard migration changes nothing in the vhost files. - README documents: HTTP-01 SAN interim during Versio period → DNS-01 wildcard via certbot-dns-transip after migration; bootstrap procedure (self-signed fallback → real cert issuance → reload). sql - config/init.d/01-databases.sh provisions gitea/keycloak/mlflow databases + roles on first start. Idempotent only via fresh data volume — change the script after first run requires manual psql or a volume wipe. - compose env extended with GITEA_DB_PASSWORD, KEYCLOAK_DB_PASSWORD, MLFLOW_DB_PASSWORD. cloud - include: now wires nginx-proxy + sql + portainer. Other stacks stay commented for future rounds. - .env.example adds KEYCLOAK_DB_PASSWORD and sensible defaults (LETSENCRYPT_EMAIL, GRAFANA_ROOT_URL, KEYCLOAK_HOSTNAME, GITEA_ROOT_URL, POSTFIX_FROM_DOMAIN all pointing at wbd-rd.nl). - Operator note inline: bring portainer's standalone instance down before deploying via cloud compose; comment its ports: block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.2 KiB
nginx-proxy
The single web ingress for cloud + edge. Reverse-proxies HTTPS UIs and stream-proxies MQTT-TLS to RabbitMQ. TLS certs managed by a certbot sidecar (Let's Encrypt).
- Image: stock
nginx:1.27-alpine(we don't usenginxproxy/nginx-proxybecause we need thestream {}context for MQTT-TLS) - Sidecar:
certbot/certbot:latest— renews every 12h via HTTP-01 webroot challenges - Networks:
edge(the only port-publisher) +app(talks to upstream services) - Host ports:
tcp/80,tcp/443,tcp/8883
Cert strategy
Interim (Versio DNS): HTTP-01 SAN cert covering all subdomains, issued via --webroot. Requires:
- Public DNS A records for each subdomain pointing at the cloud host
tcp/80reachable from the internet
After TransIP migration: switch to DNS-01 wildcard (*.wbd-rd.nl). Swap the certbot/certbot image for a build that includes certbot-dns-transip and reissue with --cert-name infra so the cert path stays stable — no vhost config changes needed.
Config layout
config/
├── nginx.conf # base — http + stream contexts
├── conf.d/
│ ├── 00-default.conf # port 80: ACME challenge + HTTPS redirect
│ ├── grafana.conf # grafana.wbd-rd.nl
│ ├── gitea.conf # gitea.wbd-rd.nl
│ ├── keycloak.conf # keycloak.wbd-rd.nl
│ ├── nodered.conf # nodered.wbd-rd.nl
│ ├── mlflow.conf # mlflow.wbd-rd.nl
│ ├── jupyter.conf # jupyter.wbd-rd.nl
│ ├── portainer.conf # portainer.wbd-rd.nl (HTTPS upstream)
│ ├── rabbitmq.conf # rabbitmq.wbd-rd.nl (mgmt UI)
│ └── jenkins.conf # jenkins.wbd-rd.nl
└── stream.d/
└── mqtt.conf # mqtt.wbd-rd.nl:8883 → rabbitmq:1883
Volumes:
nginx-certs— Let's Encrypt cert chains at/etc/letsencrypt/; read-only into nginx, writable from certbotnginx-acme-challenge— webroot for HTTP-01 challenges at/var/www/certbot/
All vhosts reference /etc/letsencrypt/live/infra/fullchain.pem and privkey.pem — a stable path independent of the issuance method.
First-run bootstrap
The HTTPS server blocks won't load without a cert at /etc/letsencrypt/live/infra/. Bootstrap procedure (one-time):
cd stacks/nginx-proxy
# 1. Self-signed fallback so nginx starts and serves /.well-known/acme-challenge/
docker compose run --rm --entrypoint=/bin/sh nginx -c \
"mkdir -p /etc/letsencrypt/live/infra && \
openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
-keyout /etc/letsencrypt/live/infra/privkey.pem \
-out /etc/letsencrypt/live/infra/fullchain.pem \
-subj '/CN=bootstrap-infra'"
# 2. Start nginx (HTTPS blocks load with the dummy cert)
docker compose up -d nginx
# 3. Issue the real cert via HTTP-01
docker compose run --rm certbot certonly \
--webroot -w /var/www/certbot \
--email "$LETSENCRYPT_EMAIL" --agree-tos --no-eff-email \
--cert-name infra \
-d grafana.wbd-rd.nl -d gitea.wbd-rd.nl -d keycloak.wbd-rd.nl \
-d nodered.wbd-rd.nl -d mlflow.wbd-rd.nl -d jupyter.wbd-rd.nl \
-d portainer.wbd-rd.nl -d rabbitmq.wbd-rd.nl -d jenkins.wbd-rd.nl \
-d mqtt.wbd-rd.nl
# 4. Reload nginx to pick up the real cert
docker compose exec nginx nginx -s reload
The certbot sidecar then renews every 12h automatically.
DNS prereqs (HTTP-01)
Before bootstrap, ensure A records exist in Versio for:
grafana.wbd-rd.nl A <cloud-public-ip>
gitea.wbd-rd.nl A <cloud-public-ip>
keycloak.wbd-rd.nl A <cloud-public-ip>
nodered.wbd-rd.nl A <cloud-public-ip>
mlflow.wbd-rd.nl A <cloud-public-ip>
jupyter.wbd-rd.nl A <cloud-public-ip>
portainer.wbd-rd.nl A <cloud-public-ip>
rabbitmq.wbd-rd.nl A <cloud-public-ip>
jenkins.wbd-rd.nl A <cloud-public-ip>
mqtt.wbd-rd.nl A <cloud-public-ip>
TODO
- Wildcard cert via
certbot-dns-transip(post TransIP migration) - OIDC
auth_requestto Keycloak in front of services without native OIDC (mlflow, portainer-CE) - Edge-side variant: bind to plant-LAN IP, internal CA for
*.localhostnames - HSTS + security headers (
add_header Strict-Transport-Security ...)