> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sevalla.com/llms.txt
> Use this file to discover all available pages before exploring further.

# EmDash

> This guide explains how to deploy an EmDash CMS site to Sevalla.

[EmDash](https://emdashcms.com/) is an open source, full-stack CMS built on [Astro](https://astro.build/) and written in TypeScript. It provides a WordPress-like admin panel for pages, posts, media, menus, and taxonomies, along with drafts, revisions, scheduled publishing, a sandboxed plugin system, a WordPress importer, and an MCP server for AI agents.

EmDash runs as a server-rendered Astro application, so you deploy it to Sevalla with Application Hosting. Static Site Hosting is not supported because the admin panel, API, and authentication all require a running server.

<Info>
  EmDash works on Sevalla, but its starter templates need a few adjustments first: the Node.js adapter, the PostgreSQL package, and the S3 storage packages. For the best experience, we created a Sevalla-flavored template that already includes these changes, along with sessions in PostgreSQL, edge cache purging, PostgreSQL full-text search, health checks, and a production-ready Dockerfile. Take a look at the [EmDash template for Sevalla](https://github.com/sevalla-templates/emdash-template).
</Info>

## Sevalla template

The [EmDash template for Sevalla](https://github.com/sevalla-templates/emdash-template) is EmDash's official blog template (posts, pages, categories, tags, search, RSS, comments, and dark mode) with the hosting layer adapted for Sevalla. It uses the following Sevalla products:

| Sevalla product | What it does |
| - | - |
| **Application Hosting** | Runs the Astro/EmDash server from the included `Dockerfile`. The container is stateless, so you can scale it horizontally. |
| **Managed PostgreSQL** | Stores content, users, settings, and sessions, and powers post search with PostgreSQL full-text search. |
| **Object Storage** | Stores uploaded media. The admin panel uploads directly to the bucket with presigned URLs, and visitors load media from the bucket's public domain. |
| **Edge Caching** | Serves rendered pages from Cloudflare's global network. When an editor publishes, the app purges the edge cache through the Sevalla API. |
| **CDN** | Serves the hashed JS, CSS, and font assets under `/_astro/` with long-term immutable caching. |

The template's [README](https://github.com/sevalla-templates/emdash-template#readme) walks through creating the database, bucket, and application step by step. If you start from your own EmDash project instead, the rest of this guide explains the changes you need to make.

## Application Hosting

### Required adjustments

EmDash's starter templates target either Cloudflare Workers (with D1 and R2 bindings) or a single Node.js server (with SQLite and media files on the local disk). Neither works on Sevalla without changes:

* **Node.js adapter** - Sevalla runs your application as a Node.js server, so you need `@astrojs/node` in standalone mode instead of the Cloudflare adapter.
* **PostgreSQL** - SQLite stores the database in a file on the local disk. Application containers on Sevalla use [ephemeral storage](/applications/storage#ephemeral-storage), so the database is lost on every deploy and can't be shared between instances. Use a Sevalla [PostgreSQL database](/databases/overview) instead, which requires the `pg` package.
* **S3-compatible storage** - Uploaded media must live outside the container for the same reason. Use Sevalla [Object Storage](/object-storage/overview) through EmDash's `s3()` adapter, which requires the AWS SDK packages.

Install the required packages:

```bash theme={null}
npm install @astrojs/node pg @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
```

<Note>
  Although you can attach [persistent storage](/applications/storage#persistent-storage) to keep SQLite and local uploads, this limits your application to a single instance. We recommend PostgreSQL and Object Storage so you can scale horizontally and deploy without downtime.
</Note>

### Configuration

We recommend the following best practices for configuring EmDash on Sevalla:

* Use `output: "server"` with the Node.js adapter in `standalone` mode.
* Connect the application to PostgreSQL through an [internal connection](/databases/networking#add-internal-connection) in the same region for lower latency and better security.
* Read database and storage credentials from runtime environment variables, and never commit them to your repository or bake them into the image.
* Store Astro sessions outside the container, for example in PostgreSQL or [Redis](/quick-starts/javascript/redis-integration), so editors stay signed in across deploys and instances.
* Set `EMDASH_SITE_URL` to your public URL before running the setup wizard, because passkeys are tied to this origin.
* Generate an `EMDASH_ENCRYPTION_KEY` with `npx emdash secrets generate` and keep a copy in a password manager.
* Set `trustedProxyHeaders` so EmDash uses the real client IP for rate limiting.
* Use `toolbar: "client"` so public HTML is the same for every visitor and can be cached at the edge.

The following is an example `astro.config.mjs` file for deploying EmDash on Sevalla:

```javascript theme={null}
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
import react from "@astrojs/react";
import emdash, { s3 } from "emdash/astro";
import { postgres } from "emdash/db";

export default defineConfig({
  output: "server",
  adapter: node({
    mode: "standalone", // Required for Sevalla deployments
  }),
  // Sevalla terminates TLS at Cloudflare and forwards requests over HTTP.
  // Trusting the forwarded protocol lets Astro and EmDash see the public https:// URL.
  security: {
    allowedDomains: [{ protocol: "https" }],
  },
  integrations: [
    react(),
    emdash({
      // Sevalla managed PostgreSQL
      database: postgres({
        connectionString: process.env.DATABASE_URL,
        pool: { max: 10, connectionTimeoutMillis: 10_000 },
      }),
      // Sevalla Object Storage, configured from the S3_* environment variables
      storage: s3(),
      toolbar: "client",
      // Cloudflare, in front of every Sevalla application, sets CF-Connecting-IP
      trustedProxyHeaders: ["cf-connecting-ip", "x-real-ip"],
    }),
  ],
});
```

<Warning>
  EmDash writes the options passed to `postgres()` into the build output. With the configuration above, `DATABASE_URL` must be available during the build process, and its value is stored in the built application. To keep credentials out of the build, load the connection string at runtime through a custom database entrypoint, as the [Sevalla template](https://github.com/sevalla-templates/emdash-template/blob/main/src/sevalla/database.ts) does.
</Warning>

#### Environment variables

Add the following [environment variables](/applications/environment-variables) to your application:

| Variable | Value |
| - | - |
| `DATABASE_URL` | The internal connection URL of your PostgreSQL database. When you [add an internal connection](/databases/networking#add-internal-connection), select **Add environment variables to the application** and rename the connection URL key to `DATABASE_URL`. |
| `EMDASH_SITE_URL` | The public URL of your site, for example, `https://emdash-abc12.sevalla.app`. |
| `EMDASH_ENCRYPTION_KEY` | The output of `npx emdash secrets generate`. Encrypts plugin secrets stored in the database. |
| `S3_ENDPOINT` | The endpoint of your Object Storage bucket. |
| `S3_BUCKET` | The name of your bucket. |
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | The access key and secret key of your bucket. |
| `S3_REGION` | `auto` |
| `S3_PUBLIC_URL` | Optional. The public domain of your bucket, for example, `https://my-bucket.sevalla.storage`. If empty, media is served through the application. |

You can find the endpoint, bucket name, and keys on your bucket's [**Settings**](/object-storage/settings) page.

#### Object Storage

The admin panel uploads media directly from the browser to the bucket with presigned URLs. To allow this, configure your bucket as follows:

1. Under [**Settings** > **CORS Policy**](/object-storage/settings#cors-policy), create a rule with your application URL as the allowed origin, `PUT`, `GET`, and `HEAD` as the allowed methods, and `content-type` as the allowed header. Add your custom domains to the rule later.
2. Optionally, enable [**Public access**](/object-storage/settings#public-access) to serve media directly from the bucket's `sevalla.storage` domain, and set it as `S3_PUBLIC_URL`.

Recent versions of the AWS SDK add checksums to presigned upload URLs by default, which Sevalla Object Storage can reject. Set the following environment variables at runtime to only send checksums when an operation requires them:

```bash theme={null}
AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED
AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED
```

<Warning>
  EmDash's automatic backups write JSON archives to the media bucket under `backups/`. If your bucket has public access enabled, anyone who guesses the file name can download them. Leave EmDash's automatic backups off and rely on Sevalla's [database backups](/databases/backups), or keep the bucket private and leave `S3_PUBLIC_URL` empty.
</Warning>

### Containerization

#### Dockerfile

The build for [Dockerfiles](/applications/build-options/dockerfile) is fully customizable. The following is an example Dockerfile for EmDash:

```dockerfile expandable theme={null}
# Dockerfile for EmDash on Sevalla

FROM node:24-slim AS deps
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund

FROM deps AS build
COPY . .

# DATABASE_URL is only needed here if your database configuration reads it at build time
ARG DATABASE_URL
RUN npm run build \
    && npm prune --omit=dev --no-audit --no-fund

FROM node:24-slim AS runtime
WORKDIR /app

ENV NODE_ENV=production \
    HOST=0.0.0.0 \
    PORT=8080

# Only send S3 checksums when required (see the Object Storage section)
ENV AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED \
    AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED

COPY --from=build --chown=node:node /app/package.json /app/package-lock.json ./
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/.emdash ./.emdash

USER node
EXPOSE 8080

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

The seed file is inlined into the build, so you don't need to copy it into the runtime image.

#### Nixpacks

You can customize the [Nixpacks](/applications/build-options/nixpacks) build process by defining a `nixpacks.toml` file and using [**Nixpacks-specific environment variables**](https://nixpacks.com/docs/configuration/environment).

The following is an example `nixpacks.toml` configuration:

```toml theme={null}
[phases.install]
cmds = ["npm ci"]

[phases.build]
cmds = ["npm run build"]

[start]
cmd = "node ./dist/server/entry.mjs"
```

EmDash requires Node.js 22.16 or later. Set the Node.js version with the `engines` field in your `package.json` or with the `NIXPACKS_NODE_VERSION` [**environment variable**](https://nixpacks.com/docs/providers/node).

#### Buildpacks

If you're using [Buildpacks](/applications/build-options/buildpacks), you cannot modify the underlying build phases directly. Buildpacks run the `build` script in your `package.json` and start the application with the `start` script:

```json theme={null}
"scripts": {
  "build": "astro build",
  "start": "node ./dist/server/entry.mjs"
},
"engines": {
  "node": ">=22.16.0"
}
```

### Database migrations

By default, EmDash applies pending core migrations on the first request after a deploy, and seeds a fresh database with your seed file. To run migrations once per deploy before new instances receive traffic, add a [job process](/applications/processes#job-process) with the **Start policy** set to **Before deployment** and a start command that runs `npx emdash migrate`.

`emdash migrate` refuses to apply migrations outside an interactive terminal unless you pass the fingerprint of the target database. The Sevalla template includes a [`migrate` script](https://github.com/sevalla-templates/emdash-template/blob/main/scripts/migrate.mjs) that reads the target database fingerprint and applies migrations non-interactively, so you can use `npm run migrate` as the job's start command.

EmDash creates and alters its own tables as you change the content model, so the database user must own the database. The default user Sevalla creates for a PostgreSQL database meets this requirement.

### CDN

Sevalla provides a premium, Cloudflare-powered CDN for Application Hosting at no additional cost. To get the most out of Sevalla's CDN when deploying your EmDash site, we recommend the following best practices:

* [Enable the CDN](/applications/cdn) for all production applications.
* Leverage Astro's fingerprinted assets in the `_astro/` directory for safe long-term caching.
* Serve uploaded media from your bucket's public domain instead of through the application.
* Upload web-sized images, because EmDash on Node.js serves original uploads without resizing.
* [Purge the CDN cache](/applications/cdn#clear-the-cdn-cache) after deploying critical updates to avoid serving stale content.

### Edge caching

[**Edge caching**](/applications/edge-caching) stores rendered pages on Cloudflare's 260+ global data centers, delivering responses from the location nearest to each visitor. A CMS is a good fit for edge caching because most pages change only when an editor publishes. To use it with EmDash, we recommend the following best practices:

* Only cache public pages. Never cache the admin panel, API, and authentication routes under `/_emdash/`, previews, or any response for a signed-in user.
* Use `toolbar: "client"` so the HTML of public pages is the same for every visitor.
* Purge the edge cache whenever content changes, so visitors see new content immediately.
* Monitor cache efficiency using the `cf-cache-status` header returned by Cloudflare.

#### `Cache-Control`

With Sevalla's Cloudflare integration, `Cache-Control` headers are respected at the edge. The following directives are useful for EmDash sites:

* `public, max-age=0, s-maxage=3600` - Caches the page on the edge for 1 hour, while browsers revalidate on every request.
* `public, max-age=31536000, immutable` - For fingerprinted assets in `/_astro/`.
* `private, no-store` - For the admin panel, API, previews, and personalized responses.

For example, in an Astro page:

```astro theme={null}
---
// src/pages/posts/[slug].astro
Astro.response.headers.set("Cache-Control", "public, max-age=0, s-maxage=3600");
---
```

#### Purge the edge cache on publish

Sevalla purges both caches after every deploy, but content changes in EmDash don't trigger a deploy. To show new content right away, call the Sevalla API to [clear the edge cache](/applications/edge-caching#clear-edge-cache) when an editor publishes, updates, or deletes content:

```typescript theme={null}
// src/lib/purge.ts
export async function purgeEdgeCache() {
  const response = await fetch(
    `https://api.sevalla.com/v3/applications/${process.env.SEVALLA_APP_ID}/purge-cache`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.SEVALLA_API_KEY}` },
    }
  );

  if (!response.ok) {
    console.error(`Edge cache purge failed: HTTP ${response.status}`);
  }
}
```

Create the [API key](/tools/api-keys) with only the permissions needed to purge the application's cache. Sevalla purges the whole application cache at once, so group bursts of edits into a single purge.

The Sevalla template implements this as an Astro cache provider and an EmDash plugin. It purges the cache after every content change, including scheduled posts that go live without a request, and never caches a response for a request that carries a session cookie. See [`src/sevalla/edge-cache.ts`](https://github.com/sevalla-templates/emdash-template/blob/main/src/sevalla/edge-cache.ts) and [`src/sevalla/purge.ts`](https://github.com/sevalla-templates/emdash-template/blob/main/src/sevalla/purge.ts).

### Health checks

Ensure your application remains available during deployments by implementing health checks:

* Always implement [**health checks**](/applications/processes#health-checks) for production applications.
* Keep checks lightweight; responses should complete in under 1 second.
* Use a basic check for the liveness probe and a check that also verifies PostgreSQL for the readiness probe.
* Return 503 only for critical failures that require pod restarts.
* Never cache health check responses.

**Health check with database verification**

```typescript theme={null}
// src/pages/healthz.ts
import type { APIRoute } from "astro";
import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 1 });

export const GET: APIRoute = async ({ url }) => {
  const headers = { "Cache-Control": "no-store", "Content-Type": "application/json" };

  if (!url.searchParams.has("db")) {
    return new Response(JSON.stringify({ status: "ok" }), { headers });
  }

  try {
    await pool.query("SELECT 1");
    return new Response(JSON.stringify({ status: "ok", db: "ok" }), { headers });
  } catch {
    return new Response(JSON.stringify({ status: "error", db: "unreachable" }), {
      status: 503,
      headers,
    });
  }
};
```

Use `/healthz` for the liveness probe and `/healthz?db=1` for the readiness probe.

### Scaling

EmDash can run on multiple instances when all state lives outside the container. Before you increase the instance count or enable [autoscaling](/applications/scalability), make sure that:

* Content is stored in PostgreSQL and media in Object Storage.
* Astro sessions are stored in a shared store. On Node.js, Astro stores sessions on the local filesystem by default, so editors are signed out on every deploy and when requests reach a different instance.
* At least one instance is always running, because EmDash's scheduler for scheduled publishing and plugin tasks runs inside the Node.js process.

### Troubleshooting

#### Build fails with `Rollup failed to resolve import "@aws-sdk/client-s3"`

* EmDash's `s3()` adapter doesn't bundle the AWS SDK. Install `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` as dependencies.

#### Media uploads fail in the admin panel

* Check that the bucket's CORS policy allows your application's origin with `PUT`, `GET`, and `HEAD`, and the `content-type` header.
* Add every custom domain you use for the admin panel to the CORS policy.
* Set `AWS_REQUEST_CHECKSUM_CALCULATION` and `AWS_RESPONSE_CHECKSUM_VALIDATION` to `WHEN_REQUIRED`.

#### Passkeys don't work after adding a custom domain

* Passkeys are tied to the origin they were registered on. Update `EMDASH_SITE_URL` to the new domain, redeploy, and register a new passkey on the new domain before removing the old one.

#### Search returns no results

* EmDash's built-in search uses SQLite full-text search and returns no results on PostgreSQL. Use PostgreSQL full-text search instead, as the Sevalla template does in [`src/sevalla/search.ts`](https://github.com/sevalla-templates/emdash-template/blob/main/src/sevalla/search.ts).

#### Migrations fail with `must be owner of table`

* EmDash needs to own its tables. Connect with the same database user that created them, and don't switch to a different user in `DATABASE_URL`.
