Lessons in Tech has moved off a managed WordPress and onto EmDash , a CMS built on Astro , running as a Node.js app behind nginx on a DigitalOcean droplet. This post is for readers who like owning their stack: you get the headline migration story, an honest look at what broke along the way, and why I would still choose this path over WordPress for this site. There are likely some issues I’ve missed in the migration, please accept my apologies for any issues.
Why EmDash instead of WordPress?
WordPress earned its dominance. For many sites, it is the right default: huge theme and plugin ecosystems, familiar hosting, and non-developers can publish without touching a terminal.
For Lessons in Tech, the landscape shifted. I wasn’t the only one who had noticed - A few WordPress leaders out there did too .
I wanted:
- A smaller runtime on the server I control. The production app is Node + systemd + a reverse proxy, not PHP, not a pile of plugins that all need to be updated on their own cadence.
- A modern front end. Astro’s server output and component model align with how I think about pages and performance; I am not fighting theme hooks and PHP templates to change layouts.
- Git and CI as the source of truth. Changes flow through a repository, automated checks, and a repeatable deploy. That is a different rhythm from “log into wp-admin and hope the updater finishes.”
- Data I can hold in my hands. SQLite and a filesystem directory for uploads are easy to back up, move, and reason about—without a separate database server unless I choose one later.
- No open source drama. The WordPress leadership drama over the last few years has been nauseating and does not seem like there’s an end in sight. EmDash is a fresh start, and this sight admin won’t have to wake up wondering about what Matt decided to block or rant about overnight.
EmDash is newer, and its ecosystem is smaller than WordPress’s. I am trading plugin breadth for clarity and control. For a technical publication, that is a fair swap.
What I am not claiming
WordPress is not “bad.” Managed WordPress is excellent when you want the vendor to own PHP versions, database sizing, and one-click installs. The pain I felt was misalignment: a developer-heavy workflow, a desire for version-controlled application code, and a preference for a single deployable artifact over a moving target of plugin compatibility matrices.
WordPress’s strength is its thousands of plugins, which is also a long-term cost. Each plugin is a dependency with its own security history and upgrade schedule. EmDash does not yet match that ecosystem; what it offers instead is a tighter boundary: the CMS, the theme, and the server runtime are the application you actually run.
Concrete advantages for EmDash
- Fewer languages and processes in production. No PHP-FPM, no WordPress cron emulation, no
.htaccesssurprises - just Node, your static build output pattern, and SQLite. - Security surface you can enumerate. A typical WordPress install accumulates attack surface with every plugin. A minimal Node app still needs patching, but the list of moving parts is shorter and visible in
package.json. - Editorial workflow in a modern admin. EmDash ships a full admin UI (including Portable Text) aimed at structured content, while the public site stays server-rendered Astro, a good fit for a site that cares about markup quality and performance.
- Predictable hosting economics. A small droplet plus your own time is a different line item than managed WordPress bundles; you pay in ops literacy instead of vendor abstraction.
What I actually run
At a high level, traffic hits nginx (TLS termination, sensible proxy headers), which forwards to a Node process listening on a local port. EmDash serves the Astro app; content lives in SQLite and media on disk, in directories outside the git checkout so a deploy never wipes the database or uploads.
On the server, systemd keeps the app running with explicit environment variables: where the database file lives, where uploads go, production mode, bind address, and a canonical site URL (more on that below). Builds and installs happen from the repository root. There is no nested “mystery” app folder in the current layout; if your unit file still pointed at an old path, you would silently run an old bundle.
I use certbot with nginx for TLS certificates on the public hostnames that point at the droplet. A special shout-out and thanks to Let’s Encrypt !
Provisioning stories worth telling
Memory: the droplet fought the build
Node builds can be hungry. On a small droplet, our first problem was predictable: pnpm build died with JavaScript heap out-of-memory.
I did not rely on a single trick to solve this one. This is a low traffic sight - one of my goals was to keep the production sizing minimal. The sequence looked like this:
- Add swap so the kernel had somewhere to park pages when RAM got tight.
- Cap the Node heap for builds and runtime. For example,
NODE_OPTIONS=--max-old-space-size=1536—so the process does not assume it owns the whole machine. - Resize the droplet to a larger RAM tier when it was clear the instance class was simply too small for comfortable builds.
If you self-host, treat “smallest possible VM” as a hypothesis, not a permanent commitment. Content sites still deserve enough headroom to build without drama.
GitHub access from the server
The app lives in a private repository. The droplet needed its own way to git pull, typically a deploy key (read-only) registered on the repo. That keeps production pulls automated without baking personal credentials into the server’s daily path.
When the error message lied
After the first deploy, the UI showed something like “EmDash is not initialized.” That string is a symptom, not a diagnosis. In our case the logs were more honest: for example Cannot find module 'kysely'—a dependency that had to be installed and declared so the running Node process could resolve it. Native modules such as better-sqlite3 also need a pnpm rebuild on Linux after installs when the toolchain changes.
The lesson: when middleware cannot attach to the database, the surface error is vague. Always read journalctl (or your process logs) for the first real stack trace.
HTTPS, passkeys, and “which origin?”
Self-hosted apps often behave as if the world is localhost. Behind nginx, the browser talks to https://lessonsintech.com , while Node on the server might initially see http://127.0.0.1:4321. Anything that cares about origins - your admin setup, passkeys, CSRF - they need a canonical public URL configured (EMDASH_SITE_URL or SITE_URL in the environment your service uses). Your reverse proxy should send Host, X-Forwarded-Host, and X-Forwarded-Proto so the app can reconstruct what the user sees.
I hit real friction around passkey-based admin setup in a beta CMS behind a proxy. Others had too as the EmDash GitHub is full of issues and comments about this. And the community has built solutions and newer releases have resolved this issue it seems. Even with the right env vars, plan for iteration: sometimes completing setup via a tunnel to localhost, or chasing an upstream fix, is part of the self-hosting bargain. Document your working combination of env, nginx headers, and EmDash version so the next deploy does not rediscover the same cliff.
systemd paths and stale bundles
If ExecStart ever pointed at an old build path (for example, a nested directory left over from an earlier repo layout), systemd would happily run yesterday’s JavaScript while you kept pulling new code. When something feels impossible after a pull, systemctl status should show exactly which entry.mjs path is running.
Day-two operations (the boring part that matters)
Routine deploys should be boring:
- Push to GitHub.
- SSH to the server,
cdto the deploy directory,git pull. pnpm install(frozen lockfile in production),pnpm rebuild better-sqlite3when native bits matter.- Build with the same heap cap you rely on (
NODE_OPTIONS=...as needed). - Restart the systemd unit and skim logs.
Never delete the directory that holds SQLite and uploads during deploy - that is your site.
Keeping GitHub as the single source of truth for code means any device or user with access can reproduce the same deploy: clone, push, pull on the server.
Closing
Migrating Lessons in Tech to EmDash was not a single checkbox. It was resize the VM, tune Node, align nginx and env with HTTPS reality, and treat misleading errors as log-driven puzzles. I would still pick this over managed WordPress for how I want to write, ship, and own the system.
If you are self-hosting a small Astro-backed CMS on a modest droplet, you are not alone - the failure modes are oddly consistent, and the fixes are learnable.
Check out EmDash
- EmDash GitHub project and documentation
No comments yet