# Deploy to Node.js

> Deploy EmDash to a long-running Node.js server with persistent data services.

Source: https://emdashcmseverything.com/official-docs/deployment/nodejs/

This guide uses SQLite and local storage for one server. Use PostgreSQL or libSQL when several instances need one database, and S3-compatible storage when media must survive independently of the server disk.

## Prerequisites

- Node.js v22.23.4 or higher (v22.16.0 is enough on Astro 7.3 or earlier)
- A Node.js hosting provider or VPS

> **Note**
>
> Node.js 22 prints `ExperimentalWarning: SQLite is an experimental feature` when a site uses the
> built-in SQLite driver. The warning comes from Node.js and does not prevent the database from
> opening. Node.js 24 does not print this warning.

## Configure the site

Configure EmDash for Node.js deployment:

```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
import emdash, { local, s3 } from "emdash/astro";
import { sqlite } from "emdash/db";

export default defineConfig({
	output: "server",
	adapter: node({ mode: "standalone" }),
	integrations: [
		emdash({
			database: sqlite({ url: "file:./data/emdash.db" }),
			storage: local({
				directory: "./data/uploads",
				baseUrl: "/_emdash/api/media/file",
			}),
		}),
	],
});
```

## Reverse proxies and HTTPS

When the Node process sits behind a reverse proxy that terminates TLS, internal requests arrive over HTTP while visitors reach the site over HTTPS. Set the public origin with `EMDASH_SITE_URL` or `SITE_URL` so EmDash builds correct absolute URLs:

```bash
export EMDASH_SITE_URL=https://example.com
```

Set the variable both where `npm run build` runs and where the server runs. The build uses it to enable image optimization for locally stored media. If it is missing at build time, images are served as unoptimized originals instead of through Astro's `/_image` endpoint. See [`siteUrl`](https://emdashcmseverything.com/official-docs/reference/configuration/#siteurl) for how EmDash resolves the public origin.

## Build and run

1. Build the project:

   ```bash
   npm run build
   ```

2. Start the server:

	```bash
	node ./dist/server/entry.mjs
	```

	Set `EMDASH_ENCRYPTION_KEY` and other runtime credentials through the hosting provider's
	process environment before starting the server. The standalone Node entry does not load `.env`
	automatically. For a local run that uses the generated `.env` file, start it with
	`node --env-file=.env ./dist/server/entry.mjs`.

The server runs on `http://localhost:4321` by default. With the default `auto` migration mode, the first request applies pending core migrations. A fresh database also receives the embedded seed. [Manage core database migrations](https://emdashcmseverything.com/official-docs/deployment/core-migrations/) explains how to migrate before restarting production traffic.

## Scheduled tasks

The built-in scheduler runs only while a Node.js process is running. It handles scheduled publishing, plugin tasks, and general maintenance.

Keep at least one Node.js process running continuously in production. Scheduled tasks pause when every process stops or sleeps.

## Plugin sandbox

Marketplace plugins and the plugins listed under `sandboxed: []` need a sandbox runner. On Node.js, the runner is `@emdash-cms/sandbox-workerd`, which runs plugins in a `workerd` child process. [Plugin Sandbox](https://emdashcmseverything.com/official-docs/deployment/plugin-sandbox/#nodejs) covers the installation, how the `workerd` process runs, and its failure modes.

## Choose production data services

Use the following pattern when the database stays on a persistent volume and media moves to S3-compatible storage:

```js title="astro.config.mjs"
import emdash, { s3 } from "emdash/astro";

export default defineConfig({
	integrations: [
			emdash({
				database: sqlite({ url: `file:${process.env.DATABASE_PATH}` }),
				storage: s3(),
		}),
	],
});
```

> **Note**
>
> S3-compatible storage works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.

## Docker

Add a `.dockerignore` to keep the build context small:

```plain title=".dockerignore"
node_modules
dist
.git
```

Create a `Dockerfile`:

```dockerfile title="Dockerfile"
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

RUN mkdir -p data

ENV HOST=0.0.0.0
ENV PORT=4321

EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]
```

The seed file is read at build time and inlined into the bundle, so it does not need to be copied into the runtime image. Migrations run on the first request after a deploy; the seed applies only when the database has no collections and setup hasn't been completed — existing data is never overwritten.

> **Note**
>
> This Dockerfile builds **your own EmDash site** — a project that has `emdash` as a dependency. It is
> not interchangeable with the `Dockerfile` in the root of the EmDash repository, which builds the whole
> pnpm monorepo and packages the blog template from source.

Build the image and run the container:

```bash
docker build -t my-emdash-site .
docker run -p 4321:4321 -v emdash-data:/app/data my-emdash-site
```

A Docker Compose file manages the same container with a named volume:

```yaml title="compose.yaml"
services:
  emdash:
    build: .
    ports:
      - "4321:4321"
    volumes:
      - emdash-data:/app/data
    restart: unless-stopped

volumes:
  emdash-data:
```

Start the stack in the background:

```bash
docker compose up -d
```

## Runtime environment

Read database and storage credentials from the process environment when the server starts. The following variables support the configuration above:

### Plugin settings encryption

`EMDASH_ENCRYPTION_KEY` encrypts plugin settings declared as secrets. A malformed value produces an operator-facing startup message, and plugin secret-setting operations fail until the value is fixed.

Generate a valid value and add the result to the server process environment:

```bash
npx emdash secrets generate  # add the result to your environment
```

The value is operator-provided and is not stored in the database. Keep it in a secret manager and in a separate recovery backup. During rotation, provide the new key first and retain older keys after commas until every plugin secret has been saved again. EmDash does not currently report which key IDs remain in use, so track each resaved credential and verify its integration before removing an old key. Restoring the database without a referenced key leaves the corresponding settings unreadable.

### Optional: stable-value overrides

EmDash auto-generates the preview HMAC secret and commenter-IP hash
salt and persists them in the database on first use. The env vars
below pin them to a value you control — useful when a separate
process needs to share a secret with your main site.

| Variable | Description |
| -------- | ----------- |
| `EMDASH_PREVIEW_SECRET` | Override for the auto-generated preview HMAC secret. |
| `EMDASH_IP_SALT` | Override for the auto-generated commenter-IP hash salt. |
| `EMDASH_AUTH_SECRET` | Optional. If set, used as the IP-salt source (unless `EMDASH_IP_SALT` is also set, which takes precedence), keeping commenter-IP hashes stable for installs that already rely on it. Leave it unset for a new deployment. |

See [Secrets and key management](https://emdashcmseverything.com/official-docs/deployment/secrets/) for the key format, every supported secret, and the effects of rotation or loss.

### Database and storage

| Variable               | Description             | Example                    |
| ---------------------- | ----------------------- | -------------------------- |
| `EMDASH_SITE_URL`      | Public site origin      | `https://example.com`      |
| `DATABASE_PATH`        | Path to SQLite database | `/data/emdash.db`        |
| `HOST`                 | Server host             | `0.0.0.0`                  |
| `PORT`                 | Server port             | `4321`                     |
| `S3_ENDPOINT`          | S3 endpoint URL         | `https://xxx.r2.cloudflarestorage.com` |
| `S3_BUCKET`            | S3 bucket name          | `my-media-bucket`          |
| `S3_ACCESS_KEY_ID`     | S3 access key           | `AKIA...`                  |
| `S3_SECRET_ACCESS_KEY` | S3 secret key           | `...`                      |
| `S3_REGION`            | S3 region               | `auto`                     |
| `S3_PUBLIC_URL`        | Public URL for media    | `https://cdn.example.com`  |

> **Caution**
>
> Never commit secrets to the repository or bake them into the container image. Use the hosting platform's runtime secret manager.

## Persistent storage

SQLite requires persistent disk storage. Ensure your hosting platform provides:

- A mounted volume or persistent disk
- Write access to the database directory
- Backup mechanisms for the database file

Back up both the SQLite file and the upload directory. Stop the process before replacing either during recovery. See [Backups](https://emdashcmseverything.com/official-docs/guides/backups/#sqlite-backup-and-recovery).

> **Caution**
>
> Ephemeral filesystems will lose your database on restart. Use libSQL with a remote database or persistent storage.

## Health checks

Add a health check endpoint for load balancers:

```astro title="src/pages/health.ts"
export const GET = () => {
  return new Response("OK", { status: 200 });
};
```

This endpoint proves that the Node.js process can serve Astro routes. It does not prove that the database, storage backend, migration state, or plugin sandbox is healthy. Verify those dependencies separately before sending traffic to a new release.

## Verify before sending traffic

After starting a new build, verify the same runtime services that production requests use:

1. Request `/health` and one public content page. Both must return a successful response.
2. Run `npx emdash migrate --check` from the built project. It must report no pending or unknown migrations for the configured database.
3. Sign in to `/_emdash/admin`, create or edit a disposable draft, and publish it. Confirm that the public page shows the change.
4. Upload a disposable media file and open its returned URL. Delete the file after verifying it.
5. If the site uses sandboxed plugins, invoke one plugin route or hook and confirm that the server log has no sandbox-unavailable or `workerd` startup error.

Keep the new instance out of the load balancer until every applicable check passes.
