# Part 2 - Set up Grav CMS to Store Your Published Markdown Files **Written by:** Jan Bachelor **Date:** 2026-08-18 [TOC] ## Introduction to Part 2 Part 1 of this series got Obsidian self-hosted with proper multi-device sync. This article covers the other half: **Grav**, the flat-file CMS that will actually serve the tutorials publicly. Grav stores everything as Markdown + YAML on disk, no database, no import/export step between "what I wrote" and "what's published" beyond restructuring the folder layout (covered in Part 3). This article deploys Grav in Docker, wires it up publicly through Cloudflare and OPNsense, and walks through the first login and some basic customization, including two real mistakes I made along the way that are worth knowing about before you hit them yourself. ### Why Grav, and why Docker again - **No database**, genuinely flat-file, so the MariaDB Galera cluster this environment already runs stays completely uninvolved. - **Docker**, matching every other service in this stack, rather than bolting it onto the existing host nginx + PHP-FPM setup that WordPress runs on. Grav needs its own PHP version/extensions; a container keeps that walled off from WordPress's shared PHP-FPM pool instead of risking cross-contamination between the two. - **Public-facing**, unlike everything in Part 1: this is the actual publishing target, so it goes through the real Cloudflare-fronted HAProxy path, not the internal-only LAN frontend. ### Prerequisites - Docker + Compose on the target web host (this used `web1`) - Host nginx already vhost-multiplexing on a shared port (`8081` in this environment) - OPNsense HAProxy with an existing public-facing frontend (the one already serving your other public sites through Cloudflare) - A Cloudflare zone with DNS management access ## Pick an image (and verify it's actually current) There's no single official Grav Docker image from the Grav project itself. I went with `websitemacherei/grav` (nginx+PHP bundled, though see the correction below), actively maintained as of writing. Two things worth checking before you trust any third-party image's README: - **Its example `docker-compose.yml` pinned `GRAV_VERSION=1.7.7`**, badly out of date. Current stable at the time of writing is `2.0.21`. Check `https://github.com/getgrav/grav/releases/latest` yourself rather than copying a README's example version. - **The README described "php-fpm and nginx handled over supervisord."** What's actually running inside the container is **Apache 2.4.67 + PHP 8.3.31 (mod_php)**. Functionally fine either way, but don't trust the description over what the container logs actually show on first boot. ## Deploy (and the gotcha that will bite you if you skip this section) ```yaml services: grav: image: websitemacherei/grav:latest container_name: grav restart: always environment: - GRAV_VERSION=2.0.21 volumes: - ./user:/var/www/html/user ports: - "127.0.0.1:8083:80" logging: driver: "journald" options: tag: "{{.Name}}" ``` **Do not just bind-mount an empty host folder onto `/var/www/html/user/` and start the container.** Unlike most CMS images where the content folder is purely "your stuff," this image's `user/` directory also ships the default theme and the config files Grav needs just to boot (`system.yaml`, etc.). Mounting an empty directory over it *masks* that default content entirely: Docker doesn't merge the image's files with your empty folder, it just hides them. The result is a startup error (`Failed to open dir: plugins:// does not exist`) and a `500` on every request. The fix, extract the image's real default `user/` content before you rely on the bind mount. Save the `docker-compose.yml` above into its own directory first: ```bash mkdir -p /opt/grav && cd /opt/grav # save the docker-compose.yml above into this directory ``` Then run the actual fix: ```bash # 1. Run a throwaway instance with no volume mount, let it fully initialize docker run -d --name grav-temp -e GRAV_VERSION=2.0.21 websitemacherei/grav:latest sleep 20 # let the entrypoint finish installing Grav core + default user/ content # 2. Extract its real user/ folder onto the host mkdir -p /opt/grav/user docker cp grav-temp:/var/www/html/user/. /opt/grav/user/ # 3. Match the container's actual www-data UID/GID (check yours - it isn't always 33:33) docker exec grav-temp id www-data chown -R 1000:33 /opt/grav/user # substitute whatever id reported # 4. Clean up the throwaway container, then bring up the real one docker rm -f grav-temp docker compose up -d ``` > 📷 **Screenshot:** the Grav homepage rendering successfully after this fix (compare against the 500 error you'd see without it) ## Host nginx vhost ```nginx server { listen 8081; server_name tutorials.bachelor-tech.com; include /etc/nginx/snippets/trusted-proxies.conf; include /etc/nginx/snippets/custom-error-403.conf; include /etc/nginx/blocklist.conf; client_max_body_size 20M; location / { proxy_pass http://127.0.0.1:8083; 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; } } ``` Save that to `/etc/nginx/sites-available/grav.conf`, symlink it into `sites-enabled/`, then test and reload: ```bash sudo ln -s /etc/nginx/sites-available/grav.conf /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx ``` Nothing exotic here compared to the internal services in Part 1, Grav serves regular page loads, no long-lived streaming connection to account for. ## HAProxy + Cloudflare, public this time The key difference from Part 1: this attaches to the **public-facing** HAProxy frontend (the one already routing your other live sites through Cloudflare), not the internal LAN-VIP frontend. - **Real server**: reuse the existing `web1_nginx` object. - **Backend pool**: Mode HTTP (Layer 7), server `web1_nginx`. No special timeout tuning needed. - **Condition + Rule**: Host header matches `tutorials.bachelor-tech.com` → route to the backend pool. - **Frontend**: attach to your existing public frontend. - **ACME**: if reusing a shared certificate object, remember the SAN-list gotcha from Part 1, add `tutorials.bachelor-tech.com` to its domain list and force a reissue, or public HTTPS will silently fail the same way the internal services did. - **Cloudflare DNS**: add a proxied CNAME record pointing `tutorials.bachelor-tech.com` at your existing origin/domain (I pointed mine at the same target `bachelor-tech.com` already uses). ## First login and initial setup Install the Admin plugin from inside the container: ```bash docker exec grav bin/gpm install admin -y ``` With no accounts yet configured, visiting `/admin` for the first time shows Grav's own account-registration form. > 📷 **Screenshot:** `05-Deploy Grav - admin interface.png`, the "Register Admin User" form on first visit to `/admin` ![Grav admin registration](05-Deploy%20Grav%20-%20admin%20interface.png) Fill in username, email, password, and full name, and log in. From the dashboard, **Plugins** in the left nav shows everything installed so far: ![Grav installed plugins](06-Grav-Admin-Plugins.png) Worth noting: this image ships with **Git Sync** pre-installed as a plugin (visible in that list), relevant groundwork for Part 3, where the actual Obsidian → Grav publishing pipeline gets built. ## A gate worth closing right after first login: Twig-in-content An article that had been live for weeks suddenly started throwing this on every visit: ``` Twig\Error\SyntaxError: Unexpected token "operator" of value "." in "@Page:/var/www/html/user/pages/14.auto-update-vaultwarden-in-docker/..." at line 191. ``` Nothing had changed about that page's own content recently, and the actual text on the reported line was an ordinary `if [ ... ]` bash conditional, nowhere near anything that looked like the problem. The real trigger was a few lines earlier, a `docker inspect` command in a code example using Docker's own Go-template output format: ```bash docker inspect -f '{{.State.Status}}' "$CONTAINER" ``` Grav was trying to parse that `{{.State.Status}}` as Twig, choking on the leading `.` (a valid Twig expression can't start with a property-access operator), and this is a plain fenced ` ```bash ` code block, nothing about it should ever be treated as a live template. Every page this pipeline generates already sets `process: { twig: false }` in its own frontmatter (see `frontmatter()` in `publish_inbox.py`, Part 3). That turned out not to be enough on its own. Grav's core `system/config/security.yaml` explains why, in its own comments: > Twig in Content, gate for editor-authored Twig inside page content. Three layers, evaluated in order: `twig_content.process_enabled`, the master gate. If false, page-content Twig is not parsed at all. `process: { twig: true }` in frontmatter is ignored. Default false on fresh 2.0 installs. **Default false on fresh installs, but the `websitemacherei/grav` image this series uses ships with it set to `true`.** A per-page `twig: false` in frontmatter is only a *request*, not an override, of the site-wide gate, and with the gate open, Grav still parses page content as a Twig template to decide whether there's anything to render, and that parse step is what throws, before the per-page setting is ever consulted. A code example containing anything that looks like `{{ ... }}` (a Docker or Kubernetes Go-template format string, a Jinja2 snippet, a Vue or Angular template, a Mustache example, LaTeX-style macros) is one bad first line away from taking the whole page down. Since this pipeline has no use for Twig-in-content anywhere, the right fix is closing the gate itself, not chasing every future code example that happens to contain a brace pair. Add this as a proper `user/config/` override rather than editing the file inside the container, an image rebuild would silently revert an in-container edit and reopen the exact same bug: ```yaml # /opt/grav/user/config/security.yaml twig_content: process_enabled: false ``` ```bash sudo docker exec grav bin/grav clearcache ``` Worth doing this early, right after the first login in the section above, rather than waiting to find out about it the way this one got found. ## Basic customization Grav's site configuration is plain YAML under `user/config/`, editable directly, no admin panel required, same pattern as everything else in this project. ```yaml # /opt/grav/user/config/site.yaml title: Bachelor-Tech Tutorials author: name: Jan Bachelor email: jan@bachelor-tech.com metadata: description: "Self-hosted IT tutorials: homelab infrastructure, high availability, and self-hosting guides." ``` Switching themes: I went with **Learn2**, Grav's own official documentation/tutorial-site theme (literally what Grav uses for their own docs), a better fit for a tutorial archive than the generic default: ```bash docker exec grav bin/gpm install learn2 -y ``` Then edit `user/config/system.yaml`'s `pages.theme` value to `learn2`, and clear the cache: ```bash docker exec grav bin/grav clearcache ``` ### Gotcha: editing files through `docker exec ... sh -c` is fragile Writing a multi-line file via a nested heredoc through `docker exec container sh -c "cat > file" < /dev/null <<'EOF' title: Bachelor-Tech Tutorials ... EOF ``` This creates one clean shell layer, no nested quoting, and it is immediately visible inside the container without a restart. > 📷 **Screenshot:** the homepage with the Learn2 theme and updated title live ## A single-language site doesn't need the /en prefix Every URL on the site came with an `/en` segment, `tutorials.bachelor-tech.com/en/some-article`, even though English is the only language this site has or will ever have. Grav's language support is on by default the moment `system.yaml` lists any `languages.supported`, `en` included, and one of the settings under it, `include_default_lang`, controls whether the default language's own URLs still carry its own prefix. It ships `true`. ```yaml # /opt/grav/user/config/system.yaml languages: supported: - en include_default_lang: false ``` ```bash docker exec grav bin/grav clearcache ``` No redirect rule needed for old `/en/...` links already out there: Grav handles that itself, requesting the old URL comes back a `302` straight to the new one, confirmed directly with `curl -I`. Worth setting `include_default_lang: false` from the very start on a single-language site, rather than discovering it after `/en` links have already been shared somewhere. ## Mobile polish: the lightbox and responsive headings None of this showed up until I actually checked the site on a phone. Four separate problems, all fixed the same way: an override in the theme's own `custom.css` (already introduced above, loaded via `assets.addCss('theme://css/custom.css', 100)` in `base.html.twig`) rather than touching any vendored file directly. ### The lightbox's thick white frame Clicking a thumbnail to zoom uses **Featherlight**, a small jQuery lightbox this theme bundles (`user/themes/learn2/js/featherlight.min.js`). Its own default CSS ships a real 25px of padding plus a white background around the enlarged image: ```css .featherlight .featherlight-content { padding: 25px 25px 0; border-bottom: 25px solid transparent; background: #fff; } ``` That's the thick frame. One thing worth knowing before you copy a fix for this: `custom.css` actually loads *before* `featherlight.min.css` in `base.html.twig` (check your own theme's load order rather than assuming), so a plain override of equal specificity loses the cascade and does nothing. The fix needs a little extra specificity to win regardless of load order: ```css body .featherlight .featherlight-content { padding: 6px 6px 0; border-bottom: 6px solid transparent; } @media only screen and (max-width: 1024px) { body .featherlight .featherlight-content { padding: 4px 4px 0; border-bottom: 4px solid transparent; } } ``` ### Making the lightbox close on a tap anywhere, not just the background By default Featherlight only closes when you click the dimmed background around the image, not the image itself, awkward on a phone where a large zoomed image can leave little to no background actually visible to tap. This one isn't a CSS fix, it's how Featherlight gets initialized, in `user/themes/learn2/js/learn.js`: ```js $('a[rel="lightbox"]').featherlight({ root: 'section#body', closeOnClick: 'anywhere' }); ``` `closeOnClick: 'anywhere'` (Featherlight's own option, not something custom) makes any click inside the lightbox close it, image included. ### Pinch-to-zoom disabled across the whole site This turned out not to be a lightbox problem at all. The theme's own viewport meta tag, in `base.html.twig`, had pinch-zoom turned off site-wide: ```html ``` `maximum-scale=1, user-scalable=no` disables it everywhere, not just inside the lightbox, a common but generally unwanted default. Removing those two directives fixes zooming site-wide: ```html ``` ### Headings that don't fit a phone screen, and lose their hierarchy if only H1 gets fixed Nucleus (this theme's base typography, `css-compiled/nucleus.css`) sets flat, desktop-sized headings with no responsive scaling at all: `h1` at `3.25rem`, `h2` at `2.55rem`, `h3` at `2.15rem`. A long article title at 52px doesn't fit a phone's width and wraps across several lines. The fix is a mobile breakpoint in `custom.css`, but it needs all three headings addressed together, shrinking only `h1` leaves `h2` visually larger than the title above it, which reads as broken hierarchy rather than a fix: ```css @media only screen and (max-width: 768px) { h1 { font-size: 2.1rem; } h2 { font-size: 1.7rem; } h3 { font-size: 1.4rem; } } ``` Keeping these roughly proportional to the original desktop sizes (`h2` a bit smaller than `h1`, `h3` smaller again) matters more than the exact numbers, adjust to taste, just don't shrink one level without checking the others still make sense next to it. ## A code block bug hiding in the theme's own CSS This one isn't mobile-specific, it shows up at every screen size, so it gets its own section rather than folding into the one above. Every multi-line code block on the site had its first line sitting a few pixels further right than every line after it, easy to mistake for a stray space typed into the source, and just as easy to miss completely unless you're looking closely. It isn't a typo. It's a real bug in Learn2's own compiled `theme.css`. The theme has a generic rule meant for short inline code spans in prose, the kind you get from a single backtick like `` `git clone` ``: ```css code { padding: .2rem .4rem; } ``` and a separate rule that overrides color, background, and font size for code sitting inside a full `
` block:

```css
pre code {
    color: #237794;
    background: inherit;
    font-size: 1rem;
}
```

That second rule never resets `padding` back to `0`, so the `.4rem` (about 6.4px) from the first rule leaks straight through onto every fenced code block on the site. Here's the part that makes it look like only the first line is affected: `` stays `display: inline` (nothing sets it to `block`), and its content includes real line breaks. CSS only ever applies an inline element's left and right padding at the very start of its first line and the end of its last line, never at an internal line break. So every line after the first sits flush against the box, and only line 1 gets pushed right by that leftover 6.4px, indistinguishable at a glance from a stray leading space.

The fix is one override, added to `custom.css` alongside everything else in this article:

```css
pre code {
    padding: 0;
}
```

Scoped specifically to code inside `
`, so ordinary inline code spans elsewhere in your prose keep their own padding and background pill.

Worth knowing if you ever go chasing something like this yourself: I only found the real cause by measuring pixel positions directly with the browser's own Range API rather than trusting a screenshot or a plain visual check. Both of those can look clean while a real, if subtle, layout bug is still sitting there.

## Letting visitors download an article

A "Download Markdown" link and a "Save as PDF" button on every article, next to the title. Two small pieces, and one gotcha each.

### The download link

Every page's folder under `user/pages/` already holds a copy of everything it needs, images included (see Part 3 for how those get copied in). Grav happily serves any of those files directly if you link to them, the same way an embedded screenshot's URL just points straight at the file sitting next to `default.md`. So the "Download Markdown" link just needs a plain markdown copy of the article sitting in that same folder, no plugin, no new route.

The gotcha: Grav refuses to serve a bare `.md` file this way. Confirmed directly, the exact same bytes come back `200` as `.txt` and `404` as `.md`, almost certainly deliberate since `.md` is Grav's own content-file extension. The fix doesn't need a workaround on Grav's side at all, it needs one on the link itself: write the file as `.txt`, but set the HTML `download` attribute to the filename you actually want:

```html
Download Markdown
```

`download` only controls what the browser saves the file as locally, it has nothing to do with the URL it was actually served from. The visitor ends up with a real `.md` file on disk either way.

### The "Save as PDF" button

No new dependency needed for this one either, just the browser's own print dialog:

```html

```

Left alone, a printed page would include the sidebar, header, footer, and every copy-to-clipboard button, none of which belong in a saved article. A `@media print` block in `custom.css` strips all of that and lets the content use the full page width instead of leaving room for the (now hidden) sidebar:

```css
@media print {
    #sidebar, #header-wrapper, #top-bar, #navigation, #body .nav,
    .copy-to-clipboard, .page-downloads, #footer, .searchbox, #overlay {
        display: none !important;
    }
    #body {
        margin-left: 0 !important;
    }
    #body .padding {
        padding: 0 !important;
    }
    a[href]:after {
        content: "" !important;
    }
}
```

That last rule matters more than it looks: browsers print the target URL after every link by default, which turns unreadable fast on a page as link-heavy as a tutorial.

### Gotcha: the two buttons looked mismatched by a couple of pixels

Same `.button-secondary` class on both, same computed padding, same computed height (42.5px, measured directly, not eyeballed), and the "Save as PDF" button still looked a couple of pixels taller than the link next to it. Not a height difference at all, it turned out, a baseline one: `` defaults to `vertical-align: baseline`, `