Shipping containers stacked in a port — a metaphor for Docker

How I Installed EmDash in Docker Without Cloudflare

A step-by-step walkthrough of running EmDash, the Astro-native CMS, in a Docker container using Node.js and SQLite — no Cloudflare account, no plugins, no magic.

EmDash is primarily designed for Cloudflare — D1 for the database, R2 for media, Workers for the plugin sandbox. But buried in the monorepo is a demo called demos/simple that runs on plain Node.js with SQLite. That is the version I containerised, and it works well.

Here is exactly what I did, including the two non-obvious fixes that took the most time.

Cloning into a non-empty directory

If your working directory already has files, git clone will refuse to run. Initialise git manually instead:

git init
git remote add origin https://github.com/emdash-cms/emdash.git
git fetch --depth=1 origin main
git checkout FETCH_HEAD

The Dockerfile

EmDash has no official Dockerfile. This one installs build tools for better-sqlite3, enables pnpm via corepack, overrides the astro config and .npmrc before installing, then builds the full monorepo.

Dockerfile
FROM node:22-bookworm-slim

# Build deps for native modules (better-sqlite3)
RUN apt-get update && apt-get install -y \
    python3 make g++ \
    && rm -rf /var/lib/apt/lists/*

RUN corepack enable && corepack prepare [email protected] --activate

WORKDIR /app
COPY . .

# Override the demo's astro config to store data in /data (mounted volume)
COPY docker-astro.config.mjs demos/simple/astro.config.mjs

# Override .npmrc to hoist all deps flat
COPY docker-.npmrc .npmrc

RUN pnpm install --frozen-lockfile

RUN pnpm -r --if-present --filter="./packages/**" build
RUN pnpm --filter="emdash-demo" build

ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321

RUN chmod +x /app/docker-entrypoint.sh
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "/app/demos/simple/dist/server/entry.mjs"]

Fix 1: shamefully-hoist

Without this, the Astro standalone server throws Cannot find module 'kysely' at runtime. pnpm does not hoist transitive workspace dependencies to root node_modules by default. Adding shamefully-hoist=true flattens the module tree and the error disappears.

docker-.npmrc
enable-pre-post-scripts=true
provenance=true
shamefully-hoist=true

Fix 2: data persistence

The default astro config uses file:./data.db which gets wiped on every rebuild. The override points everything at /data, a named Docker volume. Also enables the MCP server.

docker-astro.config.mjs
import node from "@astrojs/node";
import react from "@astrojs/react";
import { auditLogPlugin } from "@emdash-cms/plugin-audit-log";
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";

export default defineConfig({
	output: "server",
	adapter: node({ mode: "standalone" }),
	image: { layout: "constrained", responsiveStyles: true },
	integrations: [
		react(),
		emdash({
			database: sqlite({ url: "file:/data/data.db" }),
			storage: local({
				directory: "/data/uploads",
				baseUrl: "/_emdash/api/media/file",
			}),
			plugins: [auditLogPlugin()],
			mcp: true,
		}),
	],
	devToolbar: { enabled: false },
});

The entrypoint

Seeds the database on first start and moves files onto the volume. The emdash CLI is not symlinked by pnpm, so it's called directly with Node.

docker-entrypoint.sh
#!/bin/sh
set -e

EMDASH_CLI="/app/packages/core/dist/cli/index.mjs"
DEMO_DIR="/app/demos/simple"

mkdir -p /data/uploads

if [ ! -f /data/data.db ]; then
    echo "First run — seeding database..."
    cd "$DEMO_DIR"
    if node "$EMDASH_CLI" seed; then
        [ -f "$DEMO_DIR/data.db" ] && mv "$DEMO_DIR/data.db" /data/data.db
        [ -d "$DEMO_DIR/uploads" ] && cp -r "$DEMO_DIR/uploads/." /data/uploads/ \
            && rm -rf "$DEMO_DIR/uploads"
        echo "Seed complete."
    else
        echo "Warning: seeding failed. Run manually:"
        echo "  docker exec <container> sh -c 'cd /app/demos/simple && node /app/packages/core/dist/cli/index.mjs seed'"
    fi
fi

exec "$@"

docker-compose.yml

docker-compose.yml
services:
  emdash:
    build: .
    ports:
      - "4321:4321"
    volumes:
      - emdash-data:/data
    environment:
      - NODE_ENV=production
    restart: unless-stopped

volumes:
  emdash-data:

Connecting Claude Code to the MCP server

Once the container is running, add this to your Claude Code settings to write content directly from AI:

~/.claude/settings.json
{
  "mcpServers": {
    "emdash": {
      "url": "http://localhost:4321/_emdash/api/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_PAT_HERE"
      }
    }
  }
}

This post was written entirely through that endpoint. No browser, no editor.

Final thoughts

The setup took a few iterations — the shamefully-hoist fix was the hardest to find, and the data volume path mismatch needed a manual copy on first run. But the result is a proper self-hosted CMS: reproducible, portable, fully owned. Clone the repo, add these five files, run docker compose up --build, and you're done.

No comments yet