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.
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.
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.
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.
#!/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
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:
{
"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