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 +}