Self-Host EmDash CMS: Docker Guide for Homelab Servers

A practical guide to self-host Cloudflare's EmDash Astro CMS on your Docker homelab stack using Node.js, Postgres, and local file storage.

A build pipeline of nested file blocks compiling into a static output tree, pinned to a workshop wall beside blank blueprint sheets and drafting tools

Cloudflare released EmDash CMS as an open-source Astro CMS featuring native Model Context Protocol (MCP) support and sandboxed plugins. While the official documentation defaults to Cloudflare Workers, D1 databases, and R2 storage, you can self-host EmDash on any Linux server or Docker homelab stack using Node.js and PostgreSQL. Running the CMS in a container eliminates D1 write latency and keeps all content data local to your infrastructure.

This guide outlines the practical steps, configuration requirements, and common deployment traps when hosting the EmDash Astro CMS entirely off-edge.

EmDash Architecture on a Linux VPS

A self-hosted EmDash instance behaves differently than a Cloudflare Worker deployment. Instead of relying on serverless edge functions, the setup relies on Astro’s standard Node adapter.

When you run the build command, Astro compiles the CMS into a standalone Node server. This server handles routing, serves the API endpoints for the admin panel, and manages the Kysely database migrations.

The primary difference lies in the plugin architecture. Cloudflare designed EmDash to eliminate the security risks of traditional WordPress plugins by sandboxing third-party code. In a Node.js deployment, the CMS maintains this security boundary using the @emdash-cms/sandbox-workerd package. When a plugin executes, this package spawns a workerd child process inside your container. The base operating system in your Docker image must have sufficient permissions and memory limits to allow this child process to run without being terminated by the Linux Out-Of-Memory killer.

Modifying the Astro Configuration

Before you can build a Docker image, you must configure the project repository generated by npm create emdash for a server environment. The default scaffolding targets Cloudflare.

You need to modify your astro.config.mjs file. Swap the default Cloudflare adapter for @astrojs/node and set the build output to server. You also need to update the database configuration block to accept a PostgreSQL connection string, which you will pass via an environment variable in your Docker Compose file. Media storage should be set to target the local filesystem adapter pointed at an upload directory, rather than R2.

Docker Compose Setup for EmDash CMS

Deploying EmDash requires two containers: the Node.js application and a PostgreSQL database. While EmDash technically supports SQLite, using PostgreSQL in a Docker environment prevents the database lock issues that occur when concurrent writes hit a SQLite file mounted across a volume bind.

Save the following configuration as compose.yaml on your homelab server.

services:
  emdash-db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: emdash
      POSTGRES_PASSWORD: your_secure_password
      POSTGRES_DB: emdash_prod
    volumes:
      - emdash_db_data:/var/lib/postgresql/data
    networks:
      - emdash_net

  emdash-app:
    build: .
    restart: unless-stopped
    depends_on:
      - emdash-db
    environment:
      - HOST=0.0.0.0
      - PORT=4321
      - DATABASE_URL=postgres://emdash:your_secure_password@emdash-db:5432/emdash_prod
      - EMDASH_SITE_URL=https://cms.yourdomain.com
      - EMDASH_ENCRYPTION_KEY=your_generated_encryption_key
    volumes:
      - emdash_uploads:/app/uploads
    networks:
      - emdash_net
      - proxy_net

volumes:
  emdash_db_data:
  emdash_uploads:

networks:
  emdash_net:
    internal: true
  proxy_net:
    external: true

The application container relies on a custom Dockerfile placed in the same directory. The build process requires significant memory. Node’s default heap limit is often exceeded during the Vite bundling phase of the Astro build. Setting NODE_OPTIONS=--max-old-space-size=4096 prevents the build from crashing.

Recent deployment benchmarks indicate that upgrading the base image from Node 22 to Node 24 yields an approximate 38 percent increase in throughput under load.

FROM node:24-slim AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
ENV NODE_OPTIONS=--max-old-space-size=4096
RUN pnpm run build

FROM node:24-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
RUN mkdir -p /app/uploads
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

Configuring the Reverse Proxy

The proxy_net network in the Docker Compose file allows a reverse proxy to route traffic to the application. If you use Caddy, the configuration block is minimal.

cms.yourdomain.com {
    reverse_proxy emdash-app:4321
}

The reverse proxy must terminate TLS and provide a valid HTTPS certificate. EmDash is strict regarding secure contexts due to its authentication model, which breaks entirely over plain HTTP or mismatched domains.

Authentication Traps: Passkeys and Origins

The most common failure point when moving EmDash to a self-hosted environment is authentication. EmDash uses WebAuthn (Passkeys) natively. There is no traditional username and password fallback provided out of the box.

WebAuthn is tied strictly to the browser’s origin. The browser will outright reject a passkey registration or login request if the site lacks HTTPS, unless accessed via localhost. Furthermore, EmDash reads the EMDASH_SITE_URL environment variable at runtime to determine the expected WebAuthn origin.

If you access the CMS via an internal IP address (for example, 192.168.1.50) but EMDASH_SITE_URL is set to a public domain, the origin check fails and you remain locked out. Your reverse proxy domain, the certificate, and the EMDASH_SITE_URL value must match exactly.

Handling Media Storage in Your Homelab

EmDash requires a storage backend for user uploads, images, and media assets. In a Cloudflare deployment, this defaults to R2 object storage. In a Docker setup, you have two options.

The simplest approach is local disk storage. The CMS will write files directly to the container’s filesystem. The Docker Compose file maps a persistent volume to /app/uploads to ensure images survive container recreations.

Alternatively, EmDash supports S3-compatible storage endpoints natively. If your homelab already runs MinIO, or if you prefer an external provider like Bunny S3 to serve media via a pull zone, you can define the S3 credentials in your environment variables. This keeps the application container entirely stateless, removing the need for the emdash_uploads volume and simplifying server migrations.

AI Agents and MCP Traffic

EmDash ships with a native Model Context Protocol (MCP) server. This exposes your content, schema, media, and taxonomies to external AI tools like Claude or specialized agent software.

In a self-hosted Docker environment, this MCP server runs alongside your Node process. AI agents querying your CMS can generate sudden spikes in database reads and writes. This is the primary reason PostgreSQL is strictly recommended over SQLite for Docker deployments. While a single human editor might rarely encounter a SQLite database lock, an automated agent iterating through taxonomies and drafting revisions will quickly trigger concurrent write errors if the database backend cannot handle parallel operations.

Memory Limits and the Plugin Sandbox

Running the plugin sandbox locally requires accounting for memory overhead. Because @emdash-cms/sandbox-workerd initiates a separate workerd process for plugin execution, an EmDash container configured with tight memory constraints will experience silent failures.

If you cap your Docker containers using resource limits, assigning 512MB to the EmDash app container is insufficient. The Linux Out-Of-Memory killer will terminate the workerd child process when a memory-intensive plugin executes. A safe baseline for a production EmDash container is 1GB of allocated memory, allowing sufficient headroom for both the Astro Node server and the isolated plugin processes.

What’s Next

Frequently Asked Questions

Can I self-host EmDash CMS without Cloudflare?
Yes. EmDash provides an Astro Node adapter, allowing you to deploy the CMS as a standalone Node.js server in a Docker container on any Linux host.
Does EmDash CMS require a passkey to login?
Yes. EmDash uses WebAuthn passkeys by default. Your self-hosted instance must have a valid HTTPS certificate and matching origin URL for authentication to succeed.
What database does a self-hosted EmDash deployment use?
EmDash supports SQLite for local setups and PostgreSQL for production Docker deployments. PostgreSQL is recommended to prevent write locks.

Get notified when new articles and designs land:

No spam. Unsubscribe any time.

Sergej Voronko
Sergej Voronko
SAP Basis · Senior Operations Manager · Linux infrastructure engineer
About the author →

[discussion]

Comments are powered by Giscus — backed by GitHub Discussions. Sign in with GitHub to join the conversation.