plym Docs
    plym.io

    Instance configuration

    Comprehensive guide to customising plym instance

    Updated Aug 04, 2026

    On this page

    Every operator setting for a plym instance lives in one file, config.yaml, at the root of your blog directory.

    This page lists each key, its default, and what changes when you set it. Values shown as defaults are what plym uses when the key is absent — except fonts, colors, and prism.theme, which the active template usually sets for you. See Template configuration for those.

    A complete config.yaml

    name: Flapico
    description: How we ship Flapico, one deploy at a time.
    website: plym.io
    blog_home: plym.io/blog
    blog_prefix: /blog
    language: en
    template: default
    
    prism:
      enabled: true
      languages: python,bash
    
    logo: https://plym.io/assets/logo.webp
    favicon: https://plym.io/assets/favicon.ico
    
    http_cache:
      enabled: true
      max_age: 300
      index_max_age: 60
      public: true
    
    robots:
      serve: true
      disallow_paths:
        - /api/
    
    inject:
      head: ""
      body: ""
    
    pagination:
      page_size: 10
    
    reading:
      words_per_minute: 200
    
    backup:
      frequency: 7
    
    media:
      location:

    Identity

    Key Type Default Effect
    name string Plym <title> suffix, the # heading in llms.txt, the publisher in each post's JSON-LD, and site.name in templates
    description string none The > blockquote summary in llms.txt. Used nowhere else
    website string plym.local Exposed to templates as site.website. plym itself reads it nowhere
    blog_home string plym.local/blog The public URL of the blog. Every canonical link, sitemap.xml entry, and the Sitemap: line in robots.txt are built from it
    blog_prefix string /blog Path plym mounts on. Normalized to a leading slash with no trailing slash; empty string serves at the domain root
    language string en <html lang="...">
    template string default Directory name under templates/

    blog_home needs no scheme. plym prefixes https:// when one is missing, so plym.io/blog becomes https://plym.io/blog in every canonical tag.

    Set the first four with the CLI rather than by hand:

    plym set url plym.io/blog --caddy

    That writes website, blog_home, and blog_prefix to config.yaml and PLYM_BLOG_PREFIX to .env, which is what the bundled Caddy routes on. Editing blog_prefix alone changes where the api mounts while the proxy keeps serving the old path; plym reload prints a blog_prefix drift warning when the two disagree.

    Logo and favicon

    Key Type Default Effect
    logo URL none Rendered by the template as the brand mark, and used as publisher.logo in JSON-LD
    favicon URL none <link rel="icon"> on every page

    Both must be absolute http:// or https:// URLs. At startup plym downloads each one, stores it under storage/static/ with a content hash in the filename, and rewrites the reference to {blog_prefix}/static/logo-f5924e8c.webp. Visitors never hit the origin you pointed at, and the file is served with a one-year immutable cache header.

    The favicon must be a real ICO file. plym opens the download and checks the format; a PNG or WebP renamed .ico is discarded and the raw URL is used instead. The logo accepts any format Pillow reads and is converted to WebP at quality 82.

    A failed download is not fatal. plym logs asset download failed — continuing with remote logo/favicon and falls back to the URL as written.

    Syntax highlighting

    Key Type Default Effect
    prism.enabled boolean false Downloads Prism and inlines it. Off means no highlighting CSS or JS ships at all
    prism.languages string python Comma-separated PrismJS component names
    prism.theme string tomorrow One of coy, dark, funky, okaidia, solarizedlight, tomorrow, twilight

    languages is one string, not a list:

    prism:
      enabled: true
      languages: python,bash,javascript,yaml

    plym fetches prism-core.min.js plus one component per language from unpkg, concatenates them, and inlines the result in a <script> before </body>. The theme CSS joins the single inlined <style> block. Every language you list is downloaded for every page, so list the ones you write in.

    Language and theme names are PrismJS component names, and a name that doesn't exist upstream returns 404. plym logs prism download failed — continuing without prism and keeps whatever it downloaded last, so highlighting silently stays on the previous language set. Check the api logs after adding a language.

    theme is normally the template's call. enabled and languages are yours; a template that declares either fails validation.

    HTML injection

    Two operator-controlled slots put your own markup on every rendered page:

    Key Type Default Injected
    inject.head string "" Immediately before </head>, after plym's inlined <style>
    inject.body string "" Immediately before </body>, after the Prism <script>

    Google Analytics:

    inject:
      head: |
        <script async src="https://www.googletagmanager.com/gtag/js?id=G-7QX4MJ8LR2"></script>
        <script>
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config', 'G-7QX4MJ8LR2');
        </script>

    Search Console verification, a custom stylesheet, and a deferred script:

    inject:
      head: |
        <meta name="google-site-verification" content="dQw4w9WgXcQ_verification_token">
        <link rel="stylesheet" href="https://plym.io/assets/annotations.css">
      body: |
        <script defer src="https://plym.io/assets/comments.js" data-site="flapico"></script>

    Both slots go into post pages, the blog index, and editor previews. Neither goes into the markdown twin, so an agent reading Accept: text/markdown gets your content without the analytics.

    Three limits:

    • A snippet containing </head> or </body> is rejected at startup. Those tags are plym's injection anchors.
    • plym does not parse, escape, or sanitize what you write. Invalid HTML ships as invalid HTML.
    • Injection is site-wide. There is no per-post, per-category, or per-template snippet.

    Every third-party script you add here is a request the template's zero-third-party budget assumed away. Measure after you inject.

    HTTP caching

    Key Type Default Effect
    http_cache.enabled boolean true Off sends no Cache-Control header at all
    http_cache.max_age integer 300 Seconds, for post responses served by the api
    http_cache.index_max_age integer 60 Seconds, for the index, sitemap.xml, llms.txt, and the search index
    http_cache.public boolean true Picks the public or private scope token

    These headers apply to responses that reach the api. A published post is a file on disk that the bundled Caddy serves before the request gets that far, with its own fixed public, max-age=300, stale-while-revalidate=60. So max_age governs the fallback path — a post not yet rendered — while index_max_age governs the index and the SEO endpoints, which the api renders on every request.

    URL Served by Cache-Control
    /blog/<path> (published) Caddy, from storage/.generated/ public, max-age=300, stale-while-revalidate=60 (fixed)
    /blog/<path> with Accept: text/markdown Caddy public, max-age=300 (fixed)
    /blog/ api http_cache.index_max_age
    /sitemap.xml, /llms.txt api http_cache.index_max_age
    /blog/static/*, /blog/webfonts/* Caddy public, max-age=31536000, immutable (fixed)
    /robots.txt api none

    To change the numbers Caddy sends, edit docker/Caddyfile and restart the proxy.

    robots.txt

    Key Type Default Effect
    robots.serve boolean true false makes /robots.txt return 404
    robots.disallow_paths list ["/api/"] One Disallow: line per entry

    The output is generated, not stored:

    robots:
      serve: true
      disallow_paths:
        - /api/
        - /blog/plym-admin/
    User-agent: *
    Disallow: /api/
    Disallow: /blog/plym-admin/
    
    Sitemap: https://plym.io/blog/sitemap.xml

    There is one User-agent: * group. Per-crawler rules, Allow:, and Crawl-delay: are not supported — serve your own file from the proxy if you need them.

    Pagination and reading time

    Key Type Default Effect
    pagination.page_size integer 10 Posts per index page, and the default page_size for GET /api/posts
    reading.words_per_minute integer 200 Divisor for post.reading_time

    Reading time is computed when a post is rendered and stored with it. Changing words_per_minute re-renders published posts on the next restart; the number shown on a draft updates when the draft is next saved.

    Media

    Key Type Default Effect
    media.location URL none Base URL for uploaded images

    Leave it empty and uploads are served from this instance at {blog_prefix}/media/<file>.webp. Set it and the api stops serving that route, writing https://cdn.plym.io/<file>.webp into new uploads instead:

    media:
      location: https://cdn.plym.io

    Files still land in storage/_uploads/. plym does not copy them anywhere — point your CDN or bucket at that directory yourself. URLs already written into published posts are not rewritten, and the bundled Caddy keeps serving {blog_prefix}/media/* straight from storage/_uploads, so older images stay up. Switch before you upload in bulk, or re-upload afterwards.

    Backups

    Key Type Default Effect
    backup.frequency integer 7 Days between automatic backups

    An in-process scheduler writes every post, with tags, to storage/backups/posts-<timestamp>.json. The interval is frequency × 24h from api start, with a minimum of one day; 0 is clamped to 1, not treated as off. There is no retention limit — old backup files are never deleted.

    Applying changes

    config.yaml is read once, at startup. Both commands restart the api, which is what makes an edit take effect:

    plym reload
    plym rebuild

    Use plym reload for every config change. It restarts the api, which re-reads both YAML files and then reconciles the rendered files on disk: plym stamps each page with a hash of the merged config, the template's HTML, the CSS bundle, and the Prism JS, compares it against the stamp in every published page, and re-renders the ones that no longer match.

    That reconciliation covers config edits and template file edits alike. The api logs what it did:

    plym.reconcile | reconciled .generated/: 9 stale or missing file(s), re-rendering
    plym.reconcile | re-rendered 9 stale post(s)

    Use plym rebuild when a rendered file is wrong for a reason the stamp cannot see — a re-render that failed in the logs, an artifact you edited or deleted by hand, or rows changed directly in the database. It restarts the api the same way, then calls POST /api/posts/{id}/refresh on every post unconditionally, drafts included.

    Change plym reload Also needs
    name, description, language, logo, favicon Re-renders published pages
    prism.*, reading.words_per_minute Re-renders published pages
    inject.head, inject.body Re-renders published pages
    template, or edits to template files Re-renders published pages
    http_cache.*, pagination.page_size, robots.*, backup.frequency Applies at once
    media.location Applies to new uploads Re-upload existing media
    blog_prefix Remounts the api plym set url <url><prefix> so the proxy agrees
    blog_home, website Re-renders canonical tags

    Re-rendering is per post and fast — nine posts finish in about 120 ms — so reloading a large blog after a config change is not an outage.

    Frequently asked questions

    Where do database and admin credentials go?
    The config files don't any passwords or secrets, they are stored as environment variables.
    Is config.yaml file accessible through the API
    Yes, but it's read-only. To make a change in config, you will need to edit the config.yaml file and reload/rebuild the instance.