This post started as the update to EmDash 1.1, a day after this blog went live. It now covers the update to 1.2 on 9 October 2026, and it is the procedure I follow each time, on the setup from How this blog is built: EmDash on Node in a read-only Docker container under gVisor, SQLite on a volume, nginx in front, and CrowdSec in front of nginx.
The short version: read the release notes, find the database migrations, back up three things, update the packages together, approve install scripts with the right npm, build, rehearse the migrations on a copy of the database, then swap in the new build and check it by what the pages say, not just their status codes.
What changed in 1.2
Every release has an entry on EmDash's releases page, one per package. Read the emdash entries between your version and the target, and any @emdash-cms/* package you use. Look for the "What should I do?" sections, which say when a change needs something from you.
For a site like this one, 1.2 needs no config changes. The parts worth knowing:
- A Change domain dialog, a Continue on button and an Email users action in Settings > General, for moving a site to a new address. This blog moved from a test address to www.shasam.net on 1.1, which had none of these, so moving my passkey took an email provider and a recovery link instead.
- Locally stored images are optimised again on sites that set their public address with
EMDASH_SITE_URL, as this one does. On 1.1 they were served as the full-size originals. - A fix for stored cross-site scripting through the editor toolbar. Content such as an image's alt text could break out of an attribute and turn the rest into live markup.
- A video block in the editor, numbered pages in the admin's content lists, and thumbnails and dates in the recent posts widget.
- Fixes to the WordPress import: tables in Classic editor posts, links to the old site's uploads outside image blocks, and scheduled posts.
The Node adapter for Astro, @astrojs/node 11.1.7, now checks the Host header against security.allowedDomains. Every name nginx forwards to the site has to be in that list. Here that is www.shasam.net, shasam.net and the old test address.
For the record, 1.1 needed no config changes either. It added HTML blocks that can carry CSS and JavaScript, an iframe block for embeds, a publishing calendar and an optional Microsoft sign-in provider, and @astrojs/react moved to 7.0.
Find the migrations
Core migrations change EmDash's own tables, and they only go forward. Restoring the old build does not undo them, so know what is coming before you start. Download both versions and compare their migration folders:
mkdir /tmp/compare && cd /tmp/compare
npm pack emdash@1.1.0 emdash@1.2.0 --silent
for v in 1.1.0 1.2.0; do mkdir $v && tar -xzf emdash-$v.tgz -C $v; done
diff <(ls 1.1.0/package/src/database/migrations) \
<(ls 1.2.0/package/src/database/migrations)1.2 adds none. The last two came with 1.1, both for redirects: 090_redirect_enable_loop_guard and 091_redirect_artifacts.
Name the image
The compose.yaml from the first post used to build an image without naming it. Give it a name, so the running build can be tagged and brought back if the update goes wrong:
services:
emdash:
build: .
image: shasam-blog:latestRun docker compose up -d --build once with this change before your first update, so the site is running from shasam-blog:latest.
Back up three things
# The database, with SQLite's own backup command, checked.
sudo systemctl start blog-backup.service
sudo sh -c 'sqlite3 "$(ls -t /var/backups/blog/*.db | head -1)" "pragma integrity_check"'
# package.json and the lock file, so they can go back as they were.
sudo cp ~/blog/package.json ~/blog/package-lock.json /var/backups/blog/
# The image that is running now, so it can start again unchanged.
docker tag shasam-blog:latest shasam-blog:emdash-1.1.0Use sudo sh -c for anything that lists the backup folder. It is readable by root only, so a plain sudo ls /var/backups/blog/*.db fails: your own shell expands the * before sudo runs, and it cannot see inside. The integrity check then runs on an empty name and still says ok.
Update the packages
Move the EmDash packages in one command. @emdash-cms/sandbox-workerd depends on one exact emdash version, so the two always go together:
cd ~/blog
npm install emdash@^1.2.0 @emdash-cms/sandbox-workerd@^0.9.3 workerd@latest \
astro@latest @astrojs/node@latest @astrojs/react@latest
npm update
npm outdatedFor 1.2 that meant EmDash 1.2.0, the sandbox 0.9.3, workerd 1.20261008.1, Astro 7.3.8, @astrojs/node 11.1.7 and @astrojs/react 7.0.1. Node 22.16 is still the oldest version EmDash supports.
npm outdated showed one thing I left alone: TypeScript 7. @astrojs/check 0.9.10, which runs the type check, only accepts TypeScript 5 or 6, so TypeScript stays on 6 until it catches up. Check each new version's engines and peerDependencies with npm view <package>@<version> engines peerDependencies before you take it.
Approve install scripts
Recent versions of npm skip a package's install script until you approve it, one version at a time. workerd's script is what puts the real workerd program in place. Without it, node_modules/workerd/bin/workerd is a small Node script, and the plugin sandbox later fails with "workerd failed to start within 10 seconds".
Approve with the same npm the Docker build uses, because that is the one that enforces the list. Find its version, then run that version:
docker run --rm node:24-bookworm-slim npm --version
npx -y npm@11.19.0 install-scripts approve workerd
grep -A5 allowScripts package.json
head -c 4 node_modules/workerd/bin/workerd | od -c | head -1The approvals are saved under allowScripts in package.json, per version, and npm ci in the Docker build only runs the scripts listed there. The last line should show 177 E L F, the start of a Linux program, not #!.
This caught me on the 1.2 update. My workstation's npm was older and ran workerd's script without asking, so the local install looked fine and allowScripts still listed only the old workerd version. The image would have shipped without the real workerd program. Approving with the image's npm recorded the new version and dropped the stale one. Another package, miniflare, still pulls in its own older workerd, and that approval stays.
The Dockerfile
Two changes from the 1.1 update stay in place. The final image copies .emdash, where the build writes the migration manifest that emdash migrate reads, and it has a health check against EmDash's health endpoint:
COPY --from=builder /app/package.json ./
COPY --from=builder /app/.emdash ./.emdash
RUN mkdir -p /app/data && chown node:node /app/data
USER node
ENV HOST=0.0.0.0 PORT=4321
EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:4321/_emdash/api/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
CMD ["node", "./dist/server/entry.mjs"]The base image is node:24-bookworm-slim. Node 24 is the current long-term support line, and --pull on the build brings in its latest release.
Build and check the image
docker compose build --pull
docker run --rm --runtime=runsc --entrypoint sh shasam-blog:latest -c \
'uname -r; ./node_modules/workerd/bin/workerd --version; ls .emdash'Expect a kernel version ending in gvisor, a dated workerd version, and migrations.json. Building does not touch the running site: the old container keeps its image until it is replaced.
Rehearse the migrations
Copy the live database and run the migrations against the copy, in the new image, with the same restrictions the real container has:
R=$(mktemp -d)
sudo sqlite3 /var/lib/docker/volumes/blog_emdash-data/_data/emdash.db ".backup $R/emdash.db"
sudo chown -R 1000:1000 "$R"
RUN="docker run --rm --runtime=runsc --read-only --tmpfs /tmp:size=64m \
--cap-drop ALL --security-opt no-new-privileges:true -v $R:/app/data shasam-blog:latest"
E=node_modules/.bin/emdash
$RUN $E migrate --status --json > "$R/status.json"
FP=$(sed -n 's/.*"fingerprint": *"\([0-9a-f]\{64\}\)".*/\1/p' "$R/status.json" | head -1)
$RUN $E migrate --json --expected-target-fingerprint "$FP"
$RUN $E migrate --check
sudo rm -rf "$R"--status lists what is pending without changing anything. The apply step refuses to run unless the database matches the fingerprint --status reported, so it cannot land on the wrong file. --check exits non-zero if anything is still pending. User 1000 is the node user inside the image. For 1.2 it listed 90 migrations applied and none pending, as the release notes said it would.
Swap in the new build
When there are migrations, the old build should not keep serving against a schema it does not know. Take a fresh backup, stop the site for the few seconds the migration takes, migrate, then start the new build:
sudo systemctl start blog-backup.service
docker compose stop emdash
RUN="docker compose run --rm --no-deps -T emdash"
E=node_modules/.bin/emdash
FP=$($RUN $E migrate --status --json | sed -n 's/.*"fingerprint": *"\([0-9a-f]\{64\}\)".*/\1/p' | head -1)
$RUN $E migrate --json --expected-target-fingerprint "$FP"
$RUN $E migrate --check
docker compose up -ddocker compose run starts a one-off container from the service definition, so the migration runs on the real volume with the same hardening as the site. This follows the order in EmDash's guide to managing core migrations: back up, migrate before the new code takes traffic, then check.
With nothing to migrate, as with 1.2, run only the status line against the live database from the new image, confirm nothing is pending, and go straight to docker compose up -d. The site is down only while the container restarts.
On this site these steps now run as one script, scripts/deploy.sh. It takes a backup, keeps the running image as shasam-blog:previous, builds, applies migrations from the new image against the fingerprint it reports, checks none is pending, starts the container, waits for its health check, then checks the home page by content on loopback. If any check fails, it starts the previous image again.
Check it, past the bot challenge
CrowdSec puts a JavaScript challenge in front of every page for any client it hasn't verified, and the challenge page answers with HTTP 200. A check that only looks at status codes from outside, like the one in the first version of this post, reports a healthy site while every visitor is looking at a challenge. Run it from a script often enough and CrowdSec bans the script's address too.
So check from the server itself, behind CrowdSec, and look at what comes back:
docker inspect blog-emdash-1 --format \
'{{.State.Health.Status}} restarts={{.RestartCount}} {{.HostConfig.Runtime}} ro={{.HostConfig.ReadonlyRootfs}}'
docker stats --no-stream blog-emdash-1
docker compose logs --since 10m emdash
H="-H Host:www.example.com -H X-Forwarded-Proto:https"
for u in / /posts /rss.xml /sitemap.xml; do
curl -s $H "http://127.0.0.1:4321$u" -o /tmp/page
echo "$u: $(grep -oE '<title>[^<]*|<rss|<sitemapindex' /tmp/page | head -1) challenge=$(grep -c 'CrowdSec Challenge' /tmp/page)"
doneEach page should show its own title and challenge=0. Through the public address, expect the opposite for pages: the challenge, which is CrowdSec working. The feed, the sitemaps, robots.txt and /_emdash/api/ are exempt from the challenge, so they return their real content from anywhere. The container's own health check calls the app inside the container and never meets CrowdSec.
After the 1.2 update: healthy restarts=0 runsc ro=true, about 280 MB of the 768 MB limit, every sandboxed plugin listed as loaded in the log, and every page showing its title. A missing post and an unpublished draft both answered with a real 404. Then sign in to the admin, publish a small change and look at it on the site, as EmDash's update guide suggests.
If something outside the server needs to check the site, an uptime monitor or a CI job, give it its own way past the challenge, such as a secret header the CrowdSec configuration accepts in place of solving it, with the firewall rules and bans still applying.
Update the host too
The server's own packages are part of the update: Docker, gVisor (runsc), Tailscale, CrowdSec and nginx, plus Debian's security updates. unattended-upgrades takes care of Debian's, but the other repositories need a hand.
One trap: I reach this server over Tailscale SSH, and upgrading Tailscale restarts the daemon carrying that session. Run the upgrade as its own systemd unit so it finishes even if the connection drops:
sudo systemd-run --unit=apt-upgrade --collect --property=Type=oneshot \
/bin/sh -c 'DEBIAN_FRONTEND=noninteractive apt-get -y install --only-upgrade \
docker-buildx-plugin docker-compose-plugin runsc tailscale > /var/log/apt-upgrade.log 2>&1'A new runsc applies to containers started after it, so restart the site's container afterwards. Then update CrowdSec's rules with sudo cscli hub update && sudo cscli hub upgrade.
If it goes wrong
When no migration ran, as with 1.2, rolling back is just the old image:
docker tag shasam-blog:emdash-1.1.0 shasam-blog:latest
docker compose up -d --no-buildWhen a migration ran, the old image cannot run against the migrated database, so a rollback restores both together:
docker compose stop emdash
sudo sh -c 'cp "$(ls -t /var/backups/blog/emdash-*.db | head -1)" /var/lib/docker/volumes/blog_emdash-data/_data/emdash.db'
sudo rm -f /var/lib/docker/volumes/blog_emdash-data/_data/emdash.db-wal /var/lib/docker/volumes/blog_emdash-data/_data/emdash.db-shm
docker tag shasam-blog:emdash-1.1.0 shasam-blog:latest
docker compose up -d --no-buildCheck that the newest backup is the one you took before the migration. The nightly timer may have run since. Then put package.json and package-lock.json back from the backup, so the next build does not undo the rollback.
The deploy script keeps the previous image as shasam-blog:previous, so rolling back is a retag of that image and docker compose up -d.
Migrations as a deploy step
The default migration mode, auto, applies pending migrations on the first request after a restart. This site now runs with check, so a build that starts against an old database answers 503 and does not migrate it unattended:
emdash({
// ...
migrations: { runtime: "check", dev: "auto" },
});The migrate step is now part of a deploy script, so check is safe: a migration only ever runs after a backup.





No comments yet