Reverse proxy examples
Complete configuration for nginx, Caddy, Traefik, and a CDN in front of plym. What every front end must forward, which paths are safe to cache at the edge, and the three failure signatures to check for.
Updated Aug 04, 2026
On this page
Working configuration for every front end plym can sit behind — nginx, Caddy, Traefik, and a CDN.
Each file below is complete. Substitute plym.io and the port, and it runs. Use them when you configure the proxy yourself; plym set url <url> --nginx|--caddy|--traefik writes the nginx, Caddy, and Traefik versions for you, and Set up a domain covers that path.
What every front end must do
| Rule | Why |
|---|---|
| Forward the path unchanged | The stack routes on blog_prefix. A stripped prefix turns /blog/my-post into a 404 |
| Send traffic to the published port, not to the api container | The bundled Caddy serves pre-rendered files off disk and only falls through to the app on a miss |
Raise the request body limit to PLYM_UPLOAD_MAX_BYTES |
The default 10 MB upload fails at the proxy otherwise |
Pass the Accept request header through untouched |
It selects HTML or markdown for the same URL |
Leave Vary, ETag, and Cache-Control alone |
The stack sets all three per route |
X-Forwarded-Proto and X-Forwarded-For are conventional, not required. plym builds absolute URLs from blog_home and never from request headers, and its redirects are host-relative.
nginx
A subdomain, with the whole hostname going to the blog:
server {
listen 80;
listen [::]:80;
server_name blog.plym.io;
location / {
client_max_body_size 10485760;
proxy_pass http://127.0.0.1:9173;
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;
}
}
A path on a domain that already has a site, with the blog on /blog and the site on everything else:
server {
listen 80;
listen [::]:80;
server_name plym.io;
location = /blog {
client_max_body_size 10485760;
proxy_pass http://127.0.0.1:9173;
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;
}
location /blog/ {
client_max_body_size 10485760;
proxy_pass http://127.0.0.1:9173;
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;
}
location / {
root /var/www/flapico;
}
}
Both files are HTTP only. certbot --nginx -d plym.io --redirect rewrites the server block in place: it adds listen 443 ssl and the certificate paths, and makes port 80 redirect to HTTPS. Run it after the file passes nginx -t, and do not hand-write the TLS lines.
www is a separate hostname and needs its own block:
server {
listen 80;
listen [::]:80;
server_name www.plym.io;
return 301 https://plym.io$request_uri;
}
More than one blog on one domain — one location pair per install, each on its own port:
server {
listen 80;
server_name plym.io;
include /etc/nginx/plym/plym.io/*.conf;
location / {
root /var/www/flapico;
}
}
That include is what plym set url uses when the hostname already has a server block. It writes the location pair to /etc/nginx/plym/plym.io/<project>.conf and leaves your block alone, so every later blog on the domain needs no manual step.
Caddy
A subdomain. This is the whole file — Caddy obtains and renews the certificate on its own:
blog.plym.io {
reverse_proxy 127.0.0.1:9173
}
A path on a domain that serves other things:
plym.io {
handle /blog {
reverse_proxy 127.0.0.1:9173
}
handle /blog/* {
reverse_proxy 127.0.0.1:9173
}
handle {
root * /var/www/flapico
file_server
}
}
Use handle, not handle_path. handle_path strips the matched prefix, which is the one thing the stack cannot take.
www, redirected to the apex:
www.plym.io {
redir https://plym.io{uri} permanent
}
plym set url --caddy writes these to /etc/caddy/plym/<host>.caddy and appends import /etc/caddy/plym/*.caddy to /etc/caddy/Caddyfile once. When the hostname is already in your Caddyfile, plym prints the handle blocks and changes nothing.
Traefik
plym set url plym.io/blog --traefik writes docker-compose.traefik.yml and stops. It needs no sudo and skips the DNS check, because it configures nothing live:
services:
caddy:
labels:
traefik.enable: "true"
traefik.http.routers.plym-flapico.rule: Host(`plym.io`) && PathPrefix(`/blog`)
traefik.http.routers.plym-flapico.entrypoints: websecure
traefik.http.routers.plym-flapico.tls.certresolver: letsencrypt
traefik.http.services.plym-flapico.loadbalancer.server.port: "80"
Three steps finish it:
- Change
entrypointsandcertresolverto the names your Traefik actually defines.websecureandletsencryptare guesses. - Put Traefik on a Docker network this project shares, or point it at the Docker socket.
- Bring the stack up with both files:
COMPOSE_FILE=docker-compose.yml:docker-compose.traefik.yml docker compose up -d
The labels go on the caddy service and target port 80 — the bundled Caddy's port inside the network, not the published host port. Add no stripPrefix middleware; PathPrefix matches without stripping, which is what the stack needs.
For a subdomain, the rule loses its second clause:
traefik.http.routers.plym-flapico.rule: Host(`blog.plym.io`)
Any CDN
Put the CDN in front of the proxy, with your origin set to the server and the blog's host header preserved. No plym setting changes. The DNS preflight in plym set url fails while the CDN proxies the hostname, so configure the origin proxy first, or write its config by hand.
Cache the asset paths and pass the pages through. A post URL serves two representations — HTML for browsers, markdown for agents — chosen by the Accept request header and marked Vary: Accept. Cache that URL at the edge only when the CDN puts Accept in the cache key. Without it, the first response cached for a URL is served to everyone, and half your readers get the wrong content type. On Cloudflare, adding a request header to the cache key is an Enterprise-plan feature.
| Path | Cache | Reason |
|---|---|---|
/blog/static/*, /blog/webfonts/* |
Yes, for a year | Content-hashed filenames, max-age=31536000, immutable from origin |
/blog/media/* |
Yes, for a year | Every upload gets a fresh UUID filename |
/blog/<category>/<slug> |
Only with Accept in the cache key |
Two representations per URL |
/blog/ |
60 seconds, or bypass | The index is rendered by the app and lists new posts |
/blog/sitemap.xml, /blog/llms.txt, /blog/index.json |
60 seconds, or bypass | Same |
/blog/api/*, /blog/plym-admin, /mcp |
Bypass | Authenticated and mutating |
The edge buys you bandwidth and latency, not render time. Published posts are already static files on disk behind stale-while-revalidate=60, so an origin miss costs a file read.
Two settings to leave alone: HTML minification and any "rocket loader" style script rewriting. Posts ship with CSS and syntax-highlighting JS inlined, and rewriting them at the edge changes what agents read.
Verify any of them
curl -sI https://plym.io/blog/my-post
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=300, stale-while-revalidate=60
etag: "dkbehzzpg94o1a57"
vary: Accept
curl -s -H 'Accept: text/markdown' https://plym.io/blog/my-post | head -1
# My post
curl -s -D - -o /dev/null https://plym.io/blog | grep -i location
location: /blog/
That one is a GET on purpose. Routes the app serves — /blog, /blog/, sitemap.xml — accept GET only and answer curl -I with 405 Method Not Allowed. Static posts accept HEAD.
Three failure signatures:
| Response | Cause |
|---|---|
| HTML for the markdown request | The proxy or CDN rewrote or dropped Accept |
| 404 on a post that the admin lists as published | The path is being stripped, or the post was never rendered — check plym rebuild |
A redirect loop on /blog/ |
proxy_pass has a trailing slash, or a stripPrefix middleware is in the chain |
FAQ
Can the proxy talk to the api container directly? It can, and you lose the static file path. Every request then renders through the app. Point at the published port.
Does plym need X-Forwarded-Proto to emit https:// links? No. Absolute URLs come from blog_home in config.yaml.
Which port do I forward to? PLYM_PORT from the blog's .env, 9173 by default. plym list prints the port for every install on the machine.
Do I need HTTP/2 or HTTP/3? No. The stack speaks HTTP/1.1 to the proxy, and what visitors negotiate is settled between them and the proxy.
Can two proxies be chained — a CDN, then nginx, then plym? Yes. Each hop follows the same rules: forward the path unchanged, and leave the caching headers as they are.