docs: add nginx reverse proxy deployment examples
Hee Sung Son committed
Mar 7, 2026 at 20:42 UTC
d0e411286e11a8631be74198124650f3759c305f
6 files changed
+585
README.md
+7
@@ -61,6 +61,13 @@ See [portal-toys](https://github.com/gosuda/portal-toys) for more examples.
61
See [docs/architecture.md](docs/architecture.md).
62
For architecture decisions, see [docs/adr/README.md](docs/adr/README.md).
63
64
+## Examples
65
+
66
+| Example | Description |
67
+|---------|-------------|
68
+| [nginx reverse proxy](docs/examples/nginx-proxy/) | Deploy Portal behind nginx with L4 SNI routing and TLS termination |
69
+| [nginx + multi-service](docs/examples/nginx-proxy-multi-service/) | Run Portal alongside other web services behind a single nginx instance |
70
+
71
## Contributing
72
73
We welcome contributions from the community!
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
new
+118
@@ -0,0 +1,118 @@
1
+# Portal relay + multiple services — docker compose deployment example.
2
+#
3
+# This example shows how to run Portal alongside other web services
4
+# behind a single nginx instance on the same host.
5
+#
6
+# Architecture:
7
+# nginx:443 (L4 stream, ssl_preread)
8
+# ├─ portal.example.com → portal:4017 (TLS passthrough, admin/API)
9
+# ├─ *.portal.example.com → portal:443 (TLS passthrough, tenant SNI)
10
+# └─ everything else → nginx:8443 (L7, TLS termination)
11
+# ├─ app-a.example.com → app-a-frontend:3000 / app-a-api:8000
12
+# └─ app-b.example.com → app-b-frontend:3000 / app-b-api:8001
13
+#
14
+# Prerequisites:
15
+# 1. Copy .env.example to .env and set all required values.
16
+# 2. Place TLS certificates in ./certs/:
17
+# - Portal: managed by portal itself (ACME via KEYLESS_DIR)
18
+# - Other services: app_a_fullchain.pem, app_a_privkey.pem, etc.
19
+# 3. Create the portal-certs directory with correct ownership (UID 65532 = nonroot in distroless):
20
+# mkdir -p ./portal-certs
21
+# sudo chown 65532:65532 ./portal-certs
22
+# chmod 755 ./portal-certs
23
+# 4. Start all services:
24
+# docker compose up -d
25
+
26
+services:
27
+
28
+ # ─── nginx ──────────────────────────────────────────────────────────────────
29
+ # Single entry point for all traffic (ports 80/443).
30
+ # Routes portal traffic via L4 SNI passthrough.
31
+ # Terminates TLS and proxies for all other services via L7.
32
+ nginx:
33
+ image: nginx:stable-alpine
34
+ container_name: nginx
35
+ ports:
36
+ - "80:80"
37
+ - "443:443"
38
+ volumes:
39
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
40
+ - ./certs:/etc/certs:ro
41
+ depends_on:
42
+ - portal
43
+ - app-a-api
44
+ - app-a-frontend
45
+ restart: unless-stopped
46
+ networks:
47
+ - default
48
+ - app-a-network
49
+ - app-b-network
50
+
51
+ # ─── portal relay ───────────────────────────────────────────────────────────
52
+ # NAT-traversal relay. Ports are internal-only — nginx routes to them
53
+ # via the docker network (L4 SNI passthrough, no TLS termination by nginx).
54
+ portal:
55
+ image: ghcr.io/gosuda/portal:2
56
+ container_name: portal
57
+ environment:
58
+ PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
59
+ BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-https://portal.example.com}
60
+ API_PORT: ${API_PORT:-4017}
61
+ SNI_PORT: ${SNI_PORT:-443}
62
+ ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
63
+ KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
64
+ CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
65
+ volumes:
66
+ - ./portal-certs:/portal-certs
67
+ expose:
68
+ - "4017"
69
+ - "443"
70
+ restart: unless-stopped
71
+ networks:
72
+ - default
73
+
74
+ # ─── App A: backend ─────────────────────────────────────────────────────────
75
+ # Replace with your actual backend service image and config.
76
+ app-a-api:
77
+ image: your-registry/app-a-api:latest
78
+ container_name: app-a-api
79
+ # environment:
80
+ # - DATABASE_URL=...
81
+ restart: unless-stopped
82
+ networks:
83
+ - app-a-network
84
+
85
+ # ─── App A: frontend ────────────────────────────────────────────────────────
86
+ app-a-frontend:
87
+ image: your-registry/app-a-frontend:latest
88
+ container_name: app-a-frontend
89
+ restart: unless-stopped
90
+ networks:
91
+ - app-a-network
92
+ depends_on:
93
+ - app-a-api
94
+
95
+ # ─── App B: backend ─────────────────────────────────────────────────────────
96
+ # Replace with your actual backend service image and config.
97
+ app-b-api:
98
+ image: your-registry/app-b-api:latest
99
+ container_name: app-b-api
100
+ restart: unless-stopped
101
+ networks:
102
+ - app-b-network
103
+
104
+ # ─── App B: frontend ────────────────────────────────────────────────────────
105
+ app-b-frontend:
106
+ image: your-registry/app-b-frontend:latest
107
+ container_name: app-b-frontend
108
+ restart: unless-stopped
109
+ networks:
110
+ - app-b-network
111
+ depends_on:
112
+ - app-b-api
113
+
114
+networks:
115
+ app-a-network:
116
+ driver: bridge
117
+ app-b-network:
118
+ driver: bridge
docs/examples/nginx-proxy-multi-service/nginx.conf
new
+220
@@ -0,0 +1,220 @@
1
+# Portal relay + multiple services — nginx reverse proxy configuration example.
2
+#
3
+# This example shows how to run Portal alongside other web services
4
+# behind a single nginx instance. nginx handles:
5
+# 1. L4 SNI routing for portal (base domain + subdomains)
6
+# 2. L7 TLS termination + reverse proxy for other services
7
+#
8
+# Replace the following domains with your own:
9
+# portal.example.com → Portal relay
10
+# app-a.example.com → Your first web application
11
+# app-b.example.com → Your second web application
12
+#
13
+# Traffic flow:
14
+# :80 → redirect to HTTPS
15
+# :443 → L4 SNI inspection (ssl_preread)
16
+# portal.example.com → portal:4017 (admin/API, TLS passthrough)
17
+# *.portal.example.com → portal:443 (tenant SNI passthrough)
18
+# everything else → 127.0.0.1:8443 (nginx L7, TLS termination)
19
+
20
+user nginx;
21
+worker_processes auto;
22
+error_log /var/log/nginx/error.log;
23
+pid /run/nginx.pid;
24
+
25
+include /usr/share/nginx/modules/*.conf;
26
+
27
+events {
28
+ worker_connections 1024;
29
+}
30
+
31
+# ─── L4: SNI-based TCP routing ────────────────────────────────────────────────
32
+# nginx peeks at the TLS ClientHello via ssl_preread to extract SNI without
33
+# terminating TLS.
34
+#
35
+# Portal base domain → portal admin/API listener (TLS passthrough)
36
+# Portal subdomains → portal SNI listener (raw TCP passthrough)
37
+# Everything else → nginx L7 for TLS termination (other services)
38
+stream {
39
+ map $ssl_preread_server_name $backend {
40
+ # Portal base domain: TLS passthrough directly to portal admin listener.
41
+ portal.example.com portal_admin;
42
+ # Portal tenant subdomains: raw TCP passthrough to portal SNI listener.
43
+ ~\.portal\.example\.com$ portal_sni;
44
+ # All other domains: forward to nginx L7 for TLS termination.
45
+ default local_https;
46
+ }
47
+
48
+ upstream portal_admin {
49
+ # Portal admin/API TLS listener. nginx does NOT terminate TLS here.
50
+ server portal:4017;
51
+ }
52
+
53
+ upstream portal_sni {
54
+ # Portal SNI listener. Relay routes by SNI and bridges raw TCP
55
+ # to the claimed reverse session. TLS is not terminated.
56
+ server portal:443;
57
+ }
58
+
59
+ upstream local_https {
60
+ # nginx's own L7 HTTPS listener for other services.
61
+ server 127.0.0.1:8443;
62
+ }
63
+
64
+ server {
65
+ listen 443;
66
+ ssl_preread on;
67
+ proxy_pass $backend;
68
+ proxy_socket_keepalive on;
69
+ proxy_connect_timeout 10s;
70
+ proxy_buffer_size 16k;
71
+ # Long timeout for portal reverse sessions (24 hours).
72
+ proxy_timeout 600s;
73
+ }
74
+}
75
+
76
+# ─── L7: TLS termination + reverse proxy for other services ──────────────────
77
+# This section handles TLS termination for non-portal domains.
78
+# Portal traffic never reaches this http block — it's handled entirely
79
+# by the stream block above via TLS passthrough.
80
+http {
81
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
82
+ '$status $body_bytes_sent "$http_referer" '
83
+ '"$http_user_agent" "$http_x_forwarded_for"';
84
+
85
+ access_log /var/log/nginx/access.log main;
86
+
87
+ sendfile on;
88
+ tcp_nopush on;
89
+ tcp_nodelay on;
90
+ keepalive_timeout 65;
91
+ types_hash_max_size 4096;
92
+
93
+ include /etc/nginx/mime.types;
94
+ default_type application/octet-stream;
95
+
96
+ include /etc/nginx/conf.d/*.conf;
97
+
98
+ map $http_upgrade $connection_upgrade {
99
+ default upgrade;
100
+ '' close;
101
+ }
102
+
103
+ # ─── Upstreams ────────────────────────────────────────────────────────────
104
+ # Define backend services here. Each upstream corresponds to a Docker
105
+ # service on the same docker network.
106
+
107
+ upstream app_a_backend {
108
+ server app-a-api:8000;
109
+ keepalive 32;
110
+ }
111
+
112
+ upstream app_a_frontend {
113
+ server app-a-frontend:3000;
114
+ keepalive 32;
115
+ }
116
+
117
+ upstream app_b_backend {
118
+ server app-b-api:8001;
119
+ keepalive 32;
120
+ }
121
+
122
+ upstream app_b_frontend {
123
+ server app-b-frontend:3000;
124
+ keepalive 32;
125
+ }
126
+
127
+ # ─── HTTP → HTTPS redirect ────────────────────────────────────────────────
128
+ server {
129
+ listen 80;
130
+ listen [::]:80;
131
+ server_name app-a.example.com app-b.example.com portal.example.com;
132
+ return 301 https://$host$request_uri;
133
+ }
134
+
135
+ # ─── app-a.example.com ────────────────────────────────────────────────────
136
+ server {
137
+ listen 8443 ssl;
138
+ listen [::]:8443 ssl;
139
+ server_name app-a.example.com;
140
+ server_tokens off;
141
+
142
+ ssl_certificate /etc/certs/app_a_fullchain.pem;
143
+ ssl_certificate_key /etc/certs/app_a_privkey.pem;
144
+
145
+ gzip on;
146
+ gzip_vary on;
147
+ gzip_min_length 1024;
148
+ gzip_types text/plain text/css application/json application/javascript
149
+ text/xml application/xml;
150
+
151
+ # API / backend endpoint (e.g. SSE or long-polling)
152
+ location /api {
153
+ proxy_pass http://app_a_backend/api;
154
+ proxy_http_version 1.1;
155
+ proxy_set_header Connection '';
156
+ proxy_buffering off;
157
+ proxy_cache off;
158
+ chunked_transfer_encoding off;
159
+ proxy_set_header Host $host;
160
+ proxy_set_header X-Real-IP $remote_addr;
161
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
162
+ proxy_set_header X-Forwarded-Proto $scheme;
163
+ proxy_read_timeout 86400s;
164
+ proxy_send_timeout 86400s;
165
+ }
166
+
167
+ # Frontend
168
+ location / {
169
+ proxy_pass http://app_a_frontend;
170
+ proxy_http_version 1.1;
171
+ proxy_set_header Host $host;
172
+ proxy_set_header X-Real-IP $remote_addr;
173
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
174
+ proxy_set_header X-Forwarded-Proto $scheme;
175
+ }
176
+ }
177
+
178
+ # ─── app-b.example.com ────────────────────────────────────────────────────
179
+ server {
180
+ listen 8443 ssl;
181
+ listen [::]:8443 ssl;
182
+ server_name app-b.example.com;
183
+ server_tokens off;
184
+
185
+ ssl_certificate /etc/certs/app_b_fullchain.pem;
186
+ ssl_certificate_key /etc/certs/app_b_privkey.pem;
187
+
188
+ gzip on;
189
+ gzip_vary on;
190
+ gzip_min_length 1024;
191
+ gzip_types text/plain text/css application/json application/javascript
192
+ text/xml application/xml;
193
+
194
+ # API / backend endpoint
195
+ location /api {
196
+ proxy_pass http://app_b_backend/api;
197
+ proxy_http_version 1.1;
198
+ proxy_set_header Connection '';
199
+ proxy_buffering off;
200
+ proxy_cache off;
201
+ chunked_transfer_encoding off;
202
+ proxy_set_header Host $host;
203
+ proxy_set_header X-Real-IP $remote_addr;
204
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
205
+ proxy_set_header X-Forwarded-Proto $scheme;
206
+ proxy_read_timeout 86400s;
207
+ proxy_send_timeout 86400s;
208
+ }
209
+
210
+ # Frontend
211
+ location / {
212
+ proxy_pass http://app_b_frontend;
213
+ proxy_http_version 1.1;
214
+ proxy_set_header Host $host;
215
+ proxy_set_header X-Real-IP $remote_addr;
216
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
217
+ proxy_set_header X-Forwarded-Proto $scheme;
218
+ }
219
+ }
220
+}
docs/examples/nginx-proxy/.env.example
new
+23
@@ -0,0 +1,23 @@
1
+# Portal + nginx reverse proxy configuration
2
+# Copy this file to .env and fill in the values.
3
+
4
+# Public routing (no port — nginx handles :443 externally)
5
+PORTAL_URL=https://portal.example.com
6
+BOOTSTRAP_URIS=https://portal.example.com
7
+
8
+# Internal listener ports
9
+API_PORT=4017
10
+SNI_PORT=443
11
+
12
+# Admin secret for the /admin UI
13
+ADMIN_SECRET_KEY=
14
+
15
+# Cloudflare API token (Zone:Read + DNS:Edit) for portal ACME cert issuance
16
+CLOUDFLARE_TOKEN=
17
+
18
+# Portal's own keyless TLS certificate directory
19
+KEYLESS_DIR=/portal-certs
20
+
21
+# Trust forwarded headers from nginx (required behind reverse proxy)
22
+TRUST_PROXY_HEADERS=true
23
+TRUSTED_PROXY_CIDRS=
docs/examples/nginx-proxy/docker-compose.yaml
new
+79
@@ -0,0 +1,79 @@
1
+# Portal relay — nginx reverse proxy deployment example.
2
+# Replace "portal.example.com" with your actual domain throughout.
3
+#
4
+# Architecture:
5
+# nginx:443 (L4 stream, ssl_preread)
6
+# ├─ portal.example.com → nginx:8443 (L7, TLS termination) → portal:4017
7
+# └─ *.portal.example.com → portal:443 (raw TCP SNI passthrough)
8
+#
9
+# Prerequisites:
10
+# 1. Copy .env.example to .env and set all required values.
11
+# 2. Place your TLS certificate files in ./certs/:
12
+# ./certs/fullchain.pem
13
+# ./certs/privkey.pem
14
+# 3. Create the portal-certs directory with correct ownership (UID 65532 = nonroot in distroless):
15
+# mkdir -p ./portal-certs
16
+# sudo chown 65532:65532 ./portal-certs
17
+# chmod 755 ./portal-certs
18
+# 4. Start all services:
19
+# docker compose up -d
20
+
21
+services:
22
+
23
+ # ─── nginx ──────────────────────────────────────────────────────────────────
24
+ # Handles all inbound traffic on ports 80 and 443.
25
+ # L4 stream block routes by SNI; L7 http block terminates TLS for root domain.
26
+ nginx:
27
+ image: nginx:stable-alpine
28
+ container_name: nginx
29
+ ports:
30
+ - "80:80"
31
+ - "443:443"
32
+ volumes:
33
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
34
+ - ./certs:/etc/nginx/certs:ro
35
+ depends_on:
36
+ - portal
37
+ restart: unless-stopped
38
+ networks:
39
+ - portal-net
40
+
41
+ # ─── portal relay ───────────────────────────────────────────────────────────
42
+ # Relay server. Ports are internal-only when behind nginx.
43
+ # nginx forwards raw TCP for tenant subdomains to portal:443.
44
+ # nginx proxies admin/API HTTP to portal:4017.
45
+ portal:
46
+ image: ghcr.io/gosuda/portal:2
47
+ container_name: portal
48
+ environment:
49
+ # Public-facing relay URL. nginx handles TLS on port 443 externally,
50
+ # so this URL should not include a port number.
51
+ PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
52
+ BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-https://portal.example.com}
53
+
54
+ # Internal listener ports (not exposed to host).
55
+ API_PORT: ${API_PORT:-4017}
56
+ SNI_PORT: ${SNI_PORT:-443}
57
+
58
+ # Admin secret for the /admin UI. Set a strong random value.
59
+ ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
60
+
61
+ # Trust X-Forwarded-For and X-Real-IP headers from nginx.
62
+ TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
63
+ TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
64
+
65
+ # Portal's own ACME-managed TLS certificates for keyless signing.
66
+ KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
67
+ CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
68
+ volumes:
69
+ - ./portal-certs:/portal-certs
70
+ expose:
71
+ - "4017"
72
+ - "443"
73
+ restart: unless-stopped
74
+ networks:
75
+ - portal-net
76
+
77
+networks:
78
+ portal-net:
79
+ driver: bridge
docs/examples/nginx-proxy/nginx.conf
new
+138
@@ -0,0 +1,138 @@
1
+# Portal relay — nginx reverse proxy configuration example.
2
+# Replace "portal.example.com" with your actual domain throughout.
3
+#
4
+# Traffic flow:
5
+# :80 → redirect to HTTPS
6
+# :443 → L4 SNI inspection (ssl_preread, no TLS termination)
7
+# portal.example.com → :8443 (nginx L7, terminates TLS) → portal:4017
8
+# *.portal.example.com → portal:443 (raw TCP passthrough, relay routes by SNI)
9
+
10
+events {
11
+ worker_connections 4096;
12
+}
13
+
14
+# ─── L4: SNI-based TCP routing ────────────────────────────────────────────────
15
+# nginx peeks at the TLS ClientHello via ssl_preread to extract SNI without
16
+# terminating TLS. Traffic is forwarded based on whether the SNI matches
17
+# the exact root domain or a wildcard subdomain.
18
+#
19
+# Root domain → nginx L7 listener (port 8443, TLS termination)
20
+# Subdomains → portal SNI listener (port 443, raw TCP passthrough)
21
+stream {
22
+ map $ssl_preread_server_name $backend {
23
+ # Exact root host: forward to nginx L7 for TLS termination.
24
+ portal.example.com admin_tls;
25
+ # Portal tenant subdomains: raw TCP passthrough to portal SNI listener.
26
+ ~\.portal\.example\.com$ portal_sni;
27
+ # Fallback (no matching SNI).
28
+ default admin_tls;
29
+ }
30
+
31
+ upstream admin_tls {
32
+ server 127.0.0.1:8443;
33
+ }
34
+
35
+ upstream portal_sni {
36
+ # Portal SNI listener. Relay routes by SNI and bridges raw TCP
37
+ # to the claimed reverse session. TLS is not terminated here.
38
+ server portal:443;
39
+ }
40
+
41
+ server {
42
+ listen 443;
43
+ ssl_preread on;
44
+ proxy_pass $backend;
45
+ proxy_connect_timeout 5s;
46
+ # Long timeout for persistent reverse sessions (24 hours).
47
+ proxy_timeout 86400s;
48
+ }
49
+}
50
+
51
+# ─── L7: TLS termination + admin/API proxy ────────────────────────────────────
52
+# nginx terminates TLS for the root domain only, then proxies HTTP/1.1 to
53
+# the portal admin/API listener on port 4017.
54
+#
55
+# HTTP/2 is intentionally disabled on this listener.
56
+# /sdk/connect depends on HTTP/1.1 connection hijacking semantics.
57
+# Do NOT add 'http2' to the listen directives below.
58
+http {
59
+ sendfile on;
60
+ tcp_nopush on;
61
+ tcp_nodelay on;
62
+ keepalive_timeout 65;
63
+
64
+ include /etc/nginx/mime.types;
65
+ default_type application/octet-stream;
66
+
67
+ gzip on;
68
+ gzip_vary on;
69
+ gzip_min_length 1024;
70
+ gzip_types text/plain text/css application/json application/javascript
71
+ text/xml application/xml application/xml+rss text/javascript;
72
+
73
+ # ── Root domain: admin/API/frontend ──────────────────────────────────────
74
+ server {
75
+ # Internal L7 listener. Receives traffic from the L4 stream block.
76
+ # Do NOT add 'http2' — /sdk/connect requires HTTP/1.1 hijacking.
77
+ listen 8443 ssl;
78
+ server_name portal.example.com;
79
+ server_tokens off;
80
+
81
+ ssl_certificate /etc/nginx/certs/fullchain.pem;
82
+ ssl_certificate_key /etc/nginx/certs/privkey.pem;
83
+
84
+ ssl_protocols TLSv1.2 TLSv1.3;
85
+ ssl_ciphers HIGH:!aNULL:!MD5;
86
+ ssl_prefer_server_ciphers on;
87
+ ssl_session_cache shared:SSL:10m;
88
+ ssl_session_timeout 10m;
89
+
90
+ # ── /sdk/connect: reverse session establishment ──────────────────────
91
+ # The relay hijacks this HTTP/1.1 connection into a long-lived raw TCP
92
+ # reverse session. After hijacking, data flows as raw bytes.
93
+ # Buffering must be disabled; timeouts must be long.
94
+ location = /sdk/connect {
95
+ proxy_pass http://portal:4017;
96
+ proxy_http_version 1.1;
97
+
98
+ proxy_set_header Host $host;
99
+ proxy_set_header X-Real-IP $remote_addr;
100
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
101
+ proxy_set_header X-Forwarded-Proto https;
102
+
103
+ # Pass through Upgrade and Connection headers for HTTP/1.1
104
+ # connection hijacking. The relay takes ownership of the connection
105
+ # after validating the lease and reverse token.
106
+ proxy_set_header Upgrade $http_upgrade;
107
+ proxy_set_header Connection $http_connection;
108
+
109
+ # Disable all buffering. Once hijacked, data is raw TCP.
110
+ proxy_buffering off;
111
+ proxy_request_buffering off;
112
+
113
+ proxy_read_timeout 86400s;
114
+ proxy_send_timeout 86400s;
115
+ }
116
+
117
+ # ── All other admin/API and frontend routes ──────────────────────────
118
+ location / {
119
+ proxy_pass http://portal:4017;
120
+ proxy_http_version 1.1;
121
+
122
+ proxy_set_header Host $host;
123
+ proxy_set_header X-Real-IP $remote_addr;
124
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
125
+ proxy_set_header X-Forwarded-Proto https;
126
+ proxy_set_header Connection "";
127
+
128
+ proxy_read_timeout 60s;
129
+ }
130
+ }
131
+
132
+ # ── HTTP → HTTPS redirect ────────────────────────────────────────────────
133
+ server {
134
+ listen 80;
135
+ server_name _;
136
+ return 301 https://$host$request_uri;
137
+ }
138
+}