---
title: Deploy to Node.js
description: Deploy EmDash to any Node.js hosting platform.
---

import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";

EmDash runs on any Node.js 22+ hosting platform. This guide covers deployment to common providers using SQLite and local or S3-compatible storage.

## Prerequisites

- Node.js v22.12.0 or higher
- A Node.js hosting provider or VPS

## Configuration

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",
			}),
		}),
	],
});
```

## Build and Run

1. Build the project:

```bash
npm run build
```

2. Initialize the database:

   ```bash
   npx emdash init --database ./data/emdash.db
   ```

3. Start the server:

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

The server runs on `http://localhost:4321` by default.

## Production Storage

For production, use S3-compatible storage instead of local filesystem:

```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({
				endpoint: process.env.S3_ENDPOINT,
				bucket: process.env.S3_BUCKET,
				accessKeyId: process.env.S3_ACCESS_KEY_ID,
				secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
				publicUrl: process.env.S3_PUBLIC_URL, // Optional CDN URL
			}),
		}),
	],
});
```

<Aside>
	S3-compatible storage works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.
</Aside>

## 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 ./
COPY --from=builder /app/seed ./seed
COPY --from=builder /app/astro.config.mjs ./

RUN mkdir -p data

ENV HOST=0.0.0.0
ENV PORT=4321

EXPOSE 4321
CMD ["sh", "-c", "npx emdash init && node ./dist/server/entry.mjs"]
```

Build and run:

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

Or use Docker Compose:

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

volumes:
  emdash-data:
```

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

## Environment Variables

### Recommended: encryption key

`EMDASH_ENCRYPTION_KEY` is used to encrypt plugin secrets at rest. The
current release validates the key but does not yet use it — plugin
secret encryption is rolling out and will start using this value
automatically when available. Set it now so your deployment is ready.

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

The key is provided by you and never stored in the database; only
encrypted ciphertext is. Back it up somewhere durable (a password
manager, KMS, or your team's secret store) — losing it means losing
every secret encrypted with it.

### 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` | Legacy. No longer required. If set, used as the IP-salt source (unless `EMDASH_IP_SALT` is also set, which wins) so existing installs keep stable commenter-IP hashes across the upgrade. New installs should ignore this. |

### Database and Storage

| Variable               | Description             | Example                    |
| ---------------------- | ----------------------- | -------------------------- |
| `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_PUBLIC_URL`        | Public URL for media    | `https://cdn.example.com`  |

<Aside type="caution">
Never commit secrets to your repository. Use your platform's secret management (environment variables, secret stores, etc.) for production.
</Aside>

## 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

<Aside type="caution">
	Ephemeral filesystems will lose your database on restart. Use libSQL with a remote database or persistent storage.
</Aside>

## 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 });
};
```

Configure your platform to check `/health` for liveness probes.
