Subdirectory
Learn how to host Plym in a subdirectory (such as /blog or /docs) on your apex domain.
Updated Aug 04, 2026
On this page
One value controls this: blog_prefix. It has to be set in two places, because two programs route on it. The api mounts its routes under the prefix, and the bundled Caddy in front of the api decides from the same prefix which requests come off disk. plym set url writes both.
Set the prefix
plym set url plym.io/blog --nginx
plym.io/blog now serves plym-flapico
Admin: https://plym.io/blog/plym-admin
That writes four values:
| File | Key | Value |
|---|---|---|
config.yaml |
blog_prefix |
/blog |
config.yaml |
blog_home |
plym.io/blog |
config.yaml |
website |
plym.io |
.env |
PLYM_BLOG_PREFIX |
/blog |
The prefix is normalized to a leading slash with no trailing slash, so blog/, /blog/, and blog all become /blog.
Editing config.yaml alone remounts the api while the proxy keeps routing the old path. plym reload, plym rebuild, and plym update all check for that and print:
blog_prefix drift: config.yaml says '/writing' but caddy is serving '/blog'.
Routing won't change until both match. Run: plym set url <your-url>/writing
What moves under the prefix
Everything a visitor or an agent touches:
| Path | Serves |
|---|---|
/blog/ |
The index. /blog returns a 308 to it |
/blog/<category>/<slug> |
A post, as static HTML off disk |
/blog/plym-admin |
The admin portal |
/blog/api/* |
The REST API |
/blog/static/*, /blog/webfonts/*, /blog/media/* |
Assets, with a one-year immutable cache header |
/blog/sitemap.xml, /blog/llms.txt, /blog/robots.txt, /blog/index.json |
SEO and search files |
Three paths stay at the domain root and are not duplicated under the prefix: /health, /mcp, and /openapi.json. /api/*, /admin, /robots.txt, and /sitemap.xml answer at both.
Forward the prefix, unchanged
Your outer proxy needs two rules — the bare prefix and everything under it — and must pass the path through as-is. The bundled Caddy expects to see /blog/my-post, not /my-post.
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;
}
The trailing slash in proxy_pass http://127.0.0.1:9173/; is the one mistake that breaks everything and looks like it should work. It strips the location prefix, so /blog/ reaches the stack as /, which redirects to /blog/ forever, and /blog/my-post arrives as /my-post and returns 404. Write the upstream with no path at all.
client_max_body_size matches PLYM_UPLOAD_MAX_BYTES in .env. Without it, nginx rejects image uploads at 1 MB with a 413 before the api sees them.
Give the main site its robots.txt entry
The main site owns plym.io/robots.txt, and your proxy does not forward it to plym. Crawlers never see the Sitemap: line plym generates, so add it to the file the main site serves:
Sitemap: https://plym.io/blog/sitemap.xml
https://plym.io/blog/robots.txt is still generated and still lists the sitemap. Search engines do not look for it there.
Prefixes you cannot use
The bundled Caddy claims these paths at the domain root before it applies the prefix rule. A blog prefix that collides with one of them is unreachable:
/api · /admin · /health · /mcp · /media · /static · /webfonts · /plym-docs · /openapi.json
plym set url does not validate this. Pick anything else — /blog, /writing, /journal, /engineering all work.
Serve at the domain root
The blog answers under /blog out of the box, and the CLI has no flag to remove the prefix. Two edits do it.
Set the prefix to an empty string in config.yaml:
website: plym.io
blog_home: plym.io
blog_prefix: ""
Then force the empty value into the Caddy container with a compose override:
services:
caddy:
environment:
PLYM_BLOG_PREFIX: ""
docker compose up -d
plym rebuild
The override file is required, not a style choice. docker-compose.yml resolves the variable as ${PLYM_BLOG_PREFIX:-/blog}, and Compose treats an empty value the same as a missing one, so emptying PLYM_BLOG_PREFIX in .env sends /blog to Caddy anyway. Setting it in docker-compose.override.yml bypasses that default and survives plym update.
The result: the index at https://plym.io/, posts at https://plym.io/my-post, the admin at https://plym.io/plym-admin, and sitemap.xml, llms.txt, and robots.txt at the root. Posts still come off disk with Vary: Accept and their markdown twins intact.
Root mode means the blog owns the whole hostname. Give it its own domain or subdomain — anything else you host there needs a path that no post slug can take.
Verify
curl -sI https://plym.io/blog/my-post | head -3
HTTP/2 200
cache-control: public, max-age=300, stale-while-revalidate=60
vary: Accept
A 200 with vary: Accept means the request reached the static file on disk. A 200 with server: uvicorn and no vary means it fell through to the app — the post is not rendered, or the path arriving at the stack is not the one it expects.