This site ran until recently as a WordPress installation at a PHP webhost. I moved it in two steps: from WordPress to EmDash, and from that webhost to a Hetzner server I run myself. Both times, Claude carried the part that used to require expertise — WordPress internals, EmDash's API, Ansible — while I kept the judgment calls, especially around security. A third step moved the domains themselves to a different registrar and DNS provider.

Pulling the content

EmDash is supposed to replace WordPress for most use cases, in particular those with heavy plugin use, while not repeating the same architectural mistakes that made plugins in WordPress so error-prone. It is based off Astro.js, a framework I had already considered to base my next (mostly static) website on. I've been on WordPress for over a decade, and loved the ability to write small snippets of PHP to immediately get something; together with the free plugins I could find here and there WordPress was my go-to-platform. Security, of course, always sucked.

A few months ago I would have hesitated moving to anything not based on plain markdown files, since I was afraid of vendor lock-in and not getting my texts out of some weirdo database. Now I fear no longer, since LLMs are so absurdly good at transforming content from one form into another (and learning about arcane database layouts as well). So I was eager to just try something new.

EmDash ships its own WordPress importer, at /_emdash/admin/import/wordpress. I ran it once with a migration key: the first pass brought over 113 posts, 33 pages and every taxonomy, and stalled on media at 96 of 159 attachments. The importer is idempotent — it matches on slug and deduplicates by content hash — so a second run picked up the rest of the images.

Posts from the classic WordPress editor arrived as a single Portable Text block with \n\n inside the spans — looked right on the page, but wasn't a real paragraph. I also had to check with a SQL query where WordPress URLs were still hiding in the content:

SELECT COUNT(*) FROM ec_posts WHERE content LIKE '%konradvoelkel.com/wp-content%';

18 posts and 8 pages matched, mostly featured_image values still pointing at WordPress.

A dreamlike continuous-scene reinterpretation of the move: floorplan, magnifying glass, nested boxes, crew and doors melting into one another

Rebuilding the theme

I didn't want to keep the old WordPress theme, but I didn't want to reinvent it from scratch either — the isometric floorplan illustration above the header, the three-column image grid on archive pages, the centred reading column, all of that was already right. EmDash's own project scaffold wires up an MCP server with the current docs from the start (.mcp.json, https://docs.emdashcms.com/mcp, the search_docs tool) — it was already in place before I touched the theme. The feeling of changing the design was very much how I imagine working with a professional designer would be - but worse, of course. Nevertheless, being able to see the result and describing changes in plain English was faster and more fun than the HTML+CSS fiddling I actually used to enjoy, too.

By the way, it is widely confusing that Emdash is also the name of a YCombinator funded startup that does some agentic shit.

From a webhost to my own server

The second step was bigger: from a managed PHP host to a Hetzner server I configure myself with Ansible. I barely knew Ansible going in. Claude had to explain the concepts to me, but didn't have to write every line for me, and every change could be tried out in a virtual machine first, before it touched the real server.

The test VM runs under plain QEMU/KVM, with a Debian 13 cloud image sized like the planned Hetzner server (8 vCPU, 16 GB RAM, 160 GB disk), SSH on localhost:2222, HTTP on localhost:8080:

make vm-start && make vm-bootstrap && make vm-site
git remote add vm ssh://admin@127.0.0.1:2222/srv/kv/repo.git
git push vm main

My goal was to make the setup reproducible and to obtain fast closed feedback loops for Claude, so that I only have to specify measurable outcomes and let it explain the system to me. GitOps and Infrastructure-as-Code fit quite well.

The domains came along the same way: a move from Hosteurope to INWX.

INWX and Cloudflare and Hetzner all have APIs that Claude can work with, so I barely touched the overly complicated dashboards I used to fear with cloud service providers.

The reason to switch my domain registrar came from Hosteurope/GoDaddy having botched my DNS records during their own move from the old Hosteurope to the mother companies GoDaddy infrastructure - I was out of email for half a day and a bit stressed out figuring out what went wrong. During the move to INWX I had no issues, because Claude guided me through the process (and, admittedly, I already know more about MX records etc. this time).

From DevOps to ClaudeOps

There's one exception: security. What a coding agent can check repeatedly and reliably — type checking, whether a build passes, whether a deploy checklist is ticked off — I let it check. For security-relevant decisions, like what belongs in the hardening role or how secrets are stored, I still want someone with real expertise to look it over too. An agent can tell me whether a rule was followed. Whether it's the right rule is still on me.

How it works in more detail

The old setup was the classic LAMP stack, rented: the webhost ran the web server, the database and PHP, WordPress sat on top, and my part was the WordPress admin and the occasional PHP snippet. The new one has more parts, but each does one thing, and every line of configuration that makes it run is a text file in a git repository. Here is what each part does, in the order a request passes through them.

Caddy

Caddy is the web server, the only process listening on ports 80 and 443. It gets a Let's Encrypt certificate for every site name in its configuration by itself, renews it, redirects HTTP to HTTPS without being asked, and speaks HTTP/3. The configuration is one file per site, and the one for this site is short enough to read in full. Trimmed to the parts that matter:

www.konradvoelkel.com {
	encode {
		zstd better
		gzip 6
	}
	root * /srv/kv/app/dist/client

	# build assets and public/ straight from disk, precompressed at deploy time
	@assets path /_astro/* /theme/* /files/* /favicon.ico
	handle @assets {
		file_server {
			precompressed br zstd gzip
		}
	}

	# everything else: pages, search, admin, API, media
	handle {
		reverse_proxy 127.0.0.1:4321
	}
}

That is the whole idea: static files come from disk, everything else is handed to the Node process on port 4321. The security headers, the content security policy and the access log are a few more lines in the same file. Nginx does all of this too, with more lines and a separate tool for the certificates. Traefik is built for the case where the services come and go, containers whose routes it reads from Docker labels or Kubernetes. I have a handful of sites on one machine and none of them moves, so a file I can read beats a system that discovers things.

Node and the Astro build

Behind Caddy runs one Node process, started by systemd as its own user and sandboxed: the only directories it can write to are the one with the database and the uploads, and its log. It serves what astro build produced. Astro is a web framework whose templates are mostly HTML with some JavaScript at the top. Here it runs in server mode, so a page is rendered on request rather than at build time, because the content lives in a database and changes when I publish. Rendered pages stay in an in-process cache until a publish invalidates them, so most requests cost nothing to compute. KaTeX and the code highlighter run on the server as well; a VPS has the CPU for that.

EmDash

EmDash is the CMS, and it is an Astro integration rather than a separate application: the admin interface under /_emdash/admin, the content API, the WordPress importer, the scheduler and the cache hooks are all part of the same Node process. Content is stored as Portable Text, a JSON format for rich text, in a SQLite database, one file next to the uploads. There is no second server to run and no connection string. Editing happens in the admin or, from the terminal, through the CLI and the REST API, which is how most of the content work during the move was scripted.

Ansible

Ansible is how the server gets built. A playbook is a list of tasks in YAML, install this package, write this file from this template, enable this unit, grouped into roles. Mine are base, hardening, web for Caddy, node, kv for this site and restic for the backups, plus one role per other project on the box. Ansible needs nothing on the server except SSH and Python: it connects, compares each task with the actual state and changes only what differs, so running it twice is harmless, and make dry shows what a run would change before it does. The whole machine, from an empty Debian 13 image to a running site, is these files plus the content from a backup.

Terraform is the other name that comes up here, and it does a different job: it creates cloud resources, servers, firewalls, DNS zones, and keeps a state file of what it made. For one server, Ansible's own Hetzner module creates the machine too, so there was no second tool to learn, and the part that needed care, what runs on the machine and how it is locked down, is Ansible's home turf anyway.

git push

A deploy is a git push to a bare repository on the server. Its post-receive hook checks the pushed commit out into a new release directory, installs the pinned dependencies, builds the site, pre-compresses the static files, flips a symlink, restarts the service and warms the cache from the sitemap; the output streams back into my terminal. A failed build never touches the symlink, so the running release stays up, and a rollback is the same script with an older commit. The last three releases are kept.

Backups

restic runs every night from a systemd timer, takes a consistent copy of the SQLite file with sqlite3 .backup, and sends the data directory, the Caddy configuration and the systemd units, encrypted, to an S3-compatible bucket. I restored from it once, into the test VM, before trusting it.

The goal I named above was a reproducible setup with feedback loops an agent can close. Every component here is a text file that a dry run can check and a VM can rehearse. A proxy that discovers its own routes, or a tool that keeps its state outside the repository, would give the agent less to read, and me too.

By the way,

it is true what they say: a small VPS is not too expensive and fun to play around with. So much more flexible than a PHP webhoster!