plym Docs
    plym.io

    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.

    Frequently asked questions

    Can I change the prefix after publishing?
    Yes, with `plym set url` command, Every post is re-rendered at the new path and the old URLs return 404 with no redirect. Add the redirects in your proxy if the old ones are indexed.
    Do two blogs fit on one domain?
    Yes. Give each install its own prefix and its own port. Installs after the first pick the next free port — 9174, 9175 — and `plym list` prints the mapping.
    Does the prefix appear in canonical tags?
    It comes from `blog_home`, which `plym set url` sets to `<host><prefix>`. Set both by hand and they can disagree; the canonical follows `blog_home`.
    Is the MCP server reachable in a subdirectory install?
    Not by default. `plym enable mcp` serves it at `/mcp` on the domain root, and a proxy forwarding only `/blog` never sends it there. Add a `/mcp` location pointing at the same port.