Add shared public edge (nginx + certbot)

One nginx owns host ports 80/443 for every project (Gitea, Musify) and
proxies by container name over infra-net and gitea_gitea; a certbot
service renews the Let's Encrypt certificates.
This commit is contained in:
2026-09-23 16:56:28 +02:00
parent deefb6d0cd
commit 24f801cf2f
10 changed files with 234 additions and 1 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
# #
# Replace every CHANGE_ME with a strong random value (`openssl rand -hex 24`; # Replace every CHANGE_ME with a strong random value (`openssl rand -hex 24`;
# the Zitadel masterkey needs exactly 32 characters) and auth.example.com # the Zitadel masterkey needs exactly 32 characters) and auth.example.com
# with the real hostname the edge (e.g. Musify's nginx) terminates TLS for. # with the real hostname the shared edge (edge/) terminates TLS for.
# --- PostgreSQL --------------------------------------------------------------- # --- PostgreSQL ---------------------------------------------------------------
POSTGRES_USER=postgres POSTGRES_USER=postgres
+3
View File
@@ -10,3 +10,6 @@ zitadel/.output/
# OS # OS
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# Edge runtime state (Let's Encrypt account, certificates, ACME challenges)
edge/certbot/
+29
View File
@@ -95,6 +95,34 @@ with an example value that assumes these repos happen to sit as sibling
folders. If yours don't, change it. Not versioned, and regenerated whenever folders. If yours don't, change it. Not versioned, and regenerated whenever
the Zitadel volume is reset. the Zitadel volume is reset.
## The public edge
`edge/compose.yml` is the only thing that publishes host ports 80/443: one
nginx that terminates TLS for every project's domains (Gitea, Musify, ...)
and proxies to their containers by name, plus a certbot container that
renews the Let's Encrypt certificates (webroot) and lets nginx reload them
every 6 hours. It joins `infra-net` and the `gitea_gitea` network (both
external, created by the stacks that own them).
```bash
docker compose -f edge/compose.yml up -d
```
- **Vhosts** live in `edge/conf.d/` (one file per project; `00-common.conf`
has the shared resolver). Every upstream is a `set $upstream ...`, resolved
per request, so a project's stack being down never stops the edge from
starting. Shared bits are in `edge/snippets/`. Check a change with
`docker exec edge-nginx nginx -t`, then `docker exec edge-nginx nginx -s reload`.
- **Certificates** are state, not versioned (`edge/certbot/`). To issue one
for new hostnames (their DNS has to already point at the host):
```bash
docker compose -f edge/compose.yml run --rm --entrypoint certbot certbot certonly --webroot -w /var/www/certbot -d host1.example.com -d host2.example.com --non-interactive --agree-tos -m you@example.com
```
Then add the `server` blocks pointing at
`/etc/letsencrypt/live/<first -d name>/`.
## Resetting ## Resetting
```bash ```bash
@@ -111,6 +139,7 @@ re-creates its piece on the next `up`, since both are idempotent.
``` ```
Infrastructure/ Infrastructure/
├─ compose.yml # postgres, zitadel, seaweedfs, rabbitmq, jaeger: the prod shape ├─ compose.yml # postgres, zitadel, seaweedfs, rabbitmq, jaeger: the prod shape
├─ edge/ # shared public nginx + certbot (prod only): compose.yml, conf.d/, snippets/
├─ compose.dev.yml # dev overlay: publishes ports, adds pgAdmin ├─ compose.dev.yml # dev overlay: publishes ports, adds pgAdmin
├─ .env.example / .env.prod.example ├─ .env.example / .env.prod.example
├─ postgres-init/ # creates the per-project application databases ├─ postgres-init/ # creates the per-project application databases
+51
View File
@@ -0,0 +1,51 @@
# Shared public edge: the only thing that publishes host ports 80/443. One
# nginx terminates TLS for every project's domains (Gitea, Musify, ...) and
# proxies to their containers by name, over the networks below. Certificates
# come from Let's Encrypt (webroot), renewed by the certbot service.
#
# docker compose -f edge/compose.yml up -d
#
# Requires `infra-net` (created by ../compose.yml) and `gitea_gitea` (created
# by the Gitea stack) to already exist.
name: edge
networks:
infra-net:
name: infra-net
external: true
gitea:
name: gitea_gitea
external: true
services:
nginx:
image: nginx:1.27-alpine
container_name: edge-nginx
# The subshell reloads nginx every 6h so renewed certificates get picked up.
command:
- /bin/sh
- -c
- "(while :; do sleep 6h; nginx -s reload; done) & exec nginx -g 'daemon off;'"
ports:
- "80:80"
- "443:443"
volumes:
- ./conf.d:/etc/nginx/conf.d:ro
- ./snippets:/etc/nginx/snippets:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/conf:/etc/letsencrypt:ro
networks: [gitea, infra-net]
restart: unless-stopped
certbot:
image: certbot/certbot
container_name: edge-certbot
entrypoint:
- /bin/sh
- -c
- "trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot --quiet; sleep 12h & wait $${!}; done"
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
restart: unless-stopped
+8
View File
@@ -0,0 +1,8 @@
# Docker's embedded DNS. Every upstream below is a `set $upstream ...` so
# nginx resolves it per request and starts even if a project's stack is down.
resolver 127.0.0.11 valid=10s;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
+24
View File
@@ -0,0 +1,24 @@
server {
listen 80;
listen [::]:80;
server_name gitea.ikerdemo.cat;
include /etc/nginx/snippets/acme-redirect.conf;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name gitea.ikerdemo.cat;
client_max_body_size 512M;
ssl_certificate /etc/letsencrypt/live/gitea.ikerdemo.cat/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gitea.ikerdemo.cat/privkey.pem;
include /etc/nginx/snippets/tls.conf;
location / {
set $upstream http://gitea:3000;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
}
}
+102
View File
@@ -0,0 +1,102 @@
# One certificate (live/musify.ikerdemo.cat) covers the five hostnames.
server {
listen 80;
listen [::]:80;
server_name musify.ikerdemo.cat musifyapi.ikerdemo.cat
musifystream.ikerdemo.cat auth.ikerdemo.cat s3.ikerdemo.cat;
include /etc/nginx/snippets/acme-redirect.conf;
}
# Web player. Uploads are posted to its own form actions.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name musify.ikerdemo.cat;
client_max_body_size 1024m;
ssl_certificate /etc/letsencrypt/live/musify.ikerdemo.cat/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/musify.ikerdemo.cat/privkey.pem;
include /etc/nginx/snippets/tls.conf;
location / {
set $upstream http://web-player:3000;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name musifyapi.ikerdemo.cat;
ssl_certificate /etc/letsencrypt/live/musify.ikerdemo.cat/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/musify.ikerdemo.cat/privkey.pem;
include /etc/nginx/snippets/tls.conf;
location / {
set $upstream http://api:5111;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
}
}
# Audio bytes. Buffering off keeps range requests (seeking) responsive.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name musifystream.ikerdemo.cat;
ssl_certificate /etc/letsencrypt/live/musify.ikerdemo.cat/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/musify.ikerdemo.cat/privkey.pem;
include /etc/nginx/snippets/tls.conf;
location / {
set $upstream http://gateway:8081;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
proxy_buffering off;
}
}
# Zitadel, shared with the other projects (hence the generic hostname).
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name auth.ikerdemo.cat;
ssl_certificate /etc/letsencrypt/live/musify.ikerdemo.cat/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/musify.ikerdemo.cat/privkey.pem;
include /etc/nginx/snippets/tls.conf;
location / {
set $upstream http://zitadel:8080;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
grpc_set_header Host $host;
}
}
# SeaweedFS S3: presigned PUT/GET straight from the browser.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name s3.ikerdemo.cat;
client_max_body_size 1024m;
ssl_certificate /etc/letsencrypt/live/musify.ikerdemo.cat/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/musify.ikerdemo.cat/privkey.pem;
include /etc/nginx/snippets/tls.conf;
location / {
set $upstream http://seaweedfs:8333;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
proxy_request_buffering off;
}
}
+7
View File
@@ -0,0 +1,7 @@
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
+7
View File
@@ -0,0 +1,7 @@
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;
+2
View File
@@ -0,0 +1,2 @@
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;