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.