Skip to main content
EmDash is a CMS built on Astro. It runs on Sevalla as an application that Ingot builds with no configuration, keeps content, users and sessions in a Sevalla PostgreSQL database, stores uploaded media in object storage, and serves pages from Cloudflare with edge caching that the application purges through the Sevalla API when an editor publishes. This guide deploys Sevalla’s EmDash template, which has all of this set up.

At a glance

Environment variables

The template reads every variable when the server starts, so none has to be available during the build.

Before you begin

  • A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See Git providers.
  • Node.js 22.16 or later on your computer, to generate the encryption key.

Use the example repository

The EmDash template for Sevalla is EmDash’s blog template with the hosting layer adapted to Sevalla: Create an empty repository on your Git provider, such as acme/storefront, then copy the template into it and generate the key that encrypts EmDash’s plugin secrets:
Keep the generated key in a password manager: you add it as EMDASH_ENCRYPTION_KEY in the deployment steps, and EmDash can’t read its stored secrets without it.

Prepare the project for Sevalla

The template needs no code changes. Its parts depend on four Sevalla services, which you create in Deploy to Sevalla in the same location, because an internal connection only works within one location:
  • A PostgreSQL database. The database user that Sevalla creates owns the database, which EmDash needs because it creates and alters its own tables.
  • The application. Ingot, the default build strategy, detects Astro with the Node adapter and starts the server with the start script, node ./server.mjs. The template also has a Dockerfile, which Ingot doesn’t use: it generates its own, and adds the patterns in the template’s .dockerignore to it. To build from a Dockerfile instead, see Deploy with a Dockerfile.
  • An object storage bucket with public access and a CORS rule, so the admin panel can upload from the browser.
  • An API key that can update the application, used to purge the edge cache.

Deploy to Sevalla

These steps build with Ingot. To build from your own Dockerfile instead, see Deploy with a Dockerfile.
1

Create the database

In the sidebar, click Databases, then click Create database. Under Database, choose PostgreSQL and version 16 or newer. Enter orders-db as the Display name, choose a Data center and a Size, then click Create database. See Create a database for every field.
2

Create the application

In the sidebar, click Applications, then click Create > Application. Under Source, click Private repository, then choose the Git provider, the Repository and the Branch. Under Location, choose the same Data center as the database. Keep the Size at H1 or choose a larger one, then click Create application. The application opens on its Overview tab with the status Not deployed yet: creating an application doesn’t deploy it.
3

Connect the database

Click the Networking tab. Under Connected services, click Add internal connection. Choose orders-db as the Service, turn on Add environment variables to the application, and click Add internal connection. The template reads the DB_URL variable that Sevalla adds. See Connect an application.
4

Create the bucket

In the sidebar, click Object storage, then click Create object storage. Enter a Name, choose the Location closest to the application, then click Create object storage. See Create a bucket.
5

Open the bucket to the admin panel

On the bucket’s Settings tab, under Public access, click Enable public access and confirm. Then, under CORS policy, click Create rule. Select PUT, GET and HEAD under Allowed methods, enter the application’s address, such as https://storefront-x7k2m.sevalla.app, under Allowed origins and content-type under Allowed headers, then click Create CORS rule. See Bucket settings.
6

Create an API key

In the sidebar, under Tools, click API keys, then click Create API key. Enter a Name, such as storefront-cache-purge, and click Custom capabilities. Under Capabilities, select Update in the App row. Under Resource scope, choose Specific app resources and select the application. Click Create API key and copy the key. Purging the edge cache needs the APP:UPDATE permission, and this key has it for this application only. See API keys.
7

Add the environment variables

On the application’s Environment variables tab, click Add environment variables. Add these variables and click Add environment variables:The template reads them when the server starts, so they don’t need to be available during the build.
8

Turn on the CDN and edge caching

On the application’s Networking tab, under CDN & Edge caching, click Update settings. Turn on CDN and Edge caching, then click Done. See CDN and edge caching.
9

Deploy

Click the Overview tab, then click Deploy now under Recent deployments. Check the Branch and click Deploy.
10

Turn on auto-deploy

To deploy every push to the branch automatically, click the Settings tab, then click Update auto-deploy in the Auto-deploy section. Turn on Auto-deploy and click Update auto-deploy. See Deployments.
See Connect with the S3 API for where each bucket value comes from.

Verify the deployment

  1. On the Deployments tab, click the deployment. Its Build log shows Ingot’s build plan: Node.js 24, which satisfies the template’s engines.node, npm from package-lock.json, and the start command from the start script.
    Build log
    Ingot prints the ! warning for every EmDash site. The Add the environment variables step in Deploy to Sevalla sets EMDASH_ENCRYPTION_KEY, so there’s nothing to do.
  2. Wait for the deployment’s status to change to Deployed. The application’s status next to its name changes to Running.
  3. Check from a terminal that the server runs and reaches PostgreSQL:
  4. Open the application’s address followed by /_emdash/admin, for example https://storefront-x7k2m.sevalla.app/_emdash/admin. The setup wizard creates the site, can load the demo content, and registers your admin passkey. EmDash applies its database migrations on the first request.
  5. Publish a post, then open the site’s home page. The post shows without waiting for the cached page to expire, because publishing purges the edge cache: the application’s runtime logs show [sevalla] Edge cache purge requested a few seconds after you publish.
With the API, Get deployment (getDeploymentV3) returns the deployment’s status, success once it’s deployed, and Get application deployment logs (getApplicationDeploymentLogsV3) returns its build log, which ends with Web has been deployed successfully. Get application runtime logs (getApplicationRuntimeLogsV3) with search finds the purge:
See Deploy with the API. If the deployment fails, open it on the Deployments tab and read its build log, then see Troubleshoot builds and deployments. If media uploads fail in the admin panel, check the bucket’s CORS rule. For the template’s own messages in the runtime logs:

Deploy changes

With Auto-deploy on, every push to the branch starts a deployment; otherwise, click Deploy now for each change. Each deployment builds the image, then replaces the web process’s instances. See Deployments.

Add health checks

The template answers /healthz when the server runs and /healthz?db=1 when it can also reach PostgreSQL. Use them as the web process’s probes, so that Sevalla restarts an instance that stops responding and sends traffic only to instances that reach the database:
1

Open the web process

On the Processes tab, open the web process’s menu and click Update process.
2

Turn on the probes

Under Health checks, turn on Liveness probe with the Path /healthz, and Readiness probe with the Path /healthz?db=1. Click Update process. The probes apply at the next deployment.
See Processes for the other probe settings.

Run migrations before each deployment

Without a job, the first request after a deployment applies EmDash’s pending migrations. To apply them once, before new instances get traffic, add a job:
1

Create the job

On the Processes tab, click Create process and choose Job.
2

Set its command

Enter Migrate as the Name and npm run migrate as the Custom start command. Keep the Start policy at Before deployment, then click Create process.
The template’s migrate script runs emdash migrate without prompts. It’s the job Ingot recommends for the template: Ingot finds migrate in package.json and lists npm run migrate among the processes it recommends, but doesn’t create it. If the job fails, the deployment fails and nothing changes for visitors. In the next deployment’s build log, the job runs after the build, as ⏩ Running Migrate (timeout: 600s)..., and ends with ✅ Migrate has been successful before the web process rolls out.

Deploy with a Dockerfile

Ingot needs no Dockerfile. Write your own when you want full control of the image: the base image, the system packages and every layer. Replace the template’s Dockerfile with this one:
Dockerfile
The build needs no environment variables, and the image holds no secrets: the application reads its variables when the server starts. Keep the template’s .dockerignore, which keeps node_modules, dist, .git, local .env files and SQLite databases out of the build. Commit and push the Dockerfile. Ingot doesn’t use it, so you can keep it in a repository that builds with Ingot. Then switch the application to the Dockerfile build strategy:
1

Choose the Dockerfile build strategy

On the application’s Settings tab, in the Build strategy section, click Update build strategy. Click Dockerfile, keep the Dockerfile path Dockerfile and the Docker context ., then click Update build strategy.
2

Deploy

Click the Overview tab, then click Deploy now under Recent deployments. Check the Branch and click Deploy.
The Update build strategy sheet with Dockerfile selected, the Dockerfile path, the Docker context and the Docker registry credentialThe Update build strategy sheet with Dockerfile selected, the Dockerfile path, the Docker context and the Docker registry credential
The deployment’s Build log shows 📦 Building and pushing docker image using Dockerfile... instead of Ingot’s build plan, and its Dockerfile tab shows your Dockerfile. When the deployment is Deployed, curl -s "https://storefront-x7k2m.sevalla.app/healthz?db=1" prints {"status":"ok","db":"ok"}, and publishing a post purges the edge cache as before. See Dockerfile for build arguments and private base images, and Deploy with the API to switch the build strategy with the API.

Add a custom domain

Custom domains need a web process larger than H1, so scale the web process first. After you add the domain on the Domains tab (see Custom domains), change EMDASH_SITE_URL to the new address, add the new address to the bucket’s CORS rule, and deploy again. CORS rules can’t be updated: delete the rule and create a new one with both origins.
Set the custom domain in EMDASH_SITE_URL before you run the setup wizard: passkeys only work on the hostname they were created on. After a change, passkeys registered on the old address, such as storefront-x7k2m.sevalla.app, stop working, and the template has no other way to sign in. EmDash’s magic links need an email provider and its OAuth sign-in needs a provider in astro.config.mjs, and the template configures neither.

Scale the application

The template keeps no state in the instance, so you can run more than one instance or turn on autoscaling. Keep at least one instance running: EmDash’s scheduler for scheduled posts runs inside the web process. See Scaling.

Deploy with the API

These calls make the same deployment as Deploy to Sevalla, Add health checks and Run migrations before each deployment, in an order that a script or an AI agent runs as it is. Each step names the Sevalla API operation, then the CLI command and the MCP server tool that do the same. You need:
  • An API key with the Company developer role in the SEVALLA_API_KEY environment variable: the steps create a database, a bucket and an API key as well as the application. The CLI reads the key from SEVALLA_API_TOKEN.
  • The repository from Use the example repository on a Git provider that’s connected to your Sevalla account. See Git providers. Creating the application checks it: when Sevalla can’t read the repository, the call returns 400 with Cannot access repository https://github.com/acme/storefront: Repository not found or you don't have access., followed by where to grant access.
  • The encryption key in EMDASH_ENCRYPTION_KEY. npx emdash secrets generate prints only the key to standard output, so in the project folder, after npm install, run EMDASH_ENCRYPTION_KEY=$(npx emdash secrets generate).
  • curl, jq and openssl.
1

Choose the location

List clusters (listClustersV3) returns the locations your company can use, each with its id, its display_name and its region ID in location (see Locations). The database and the application must be in the same one. Keep the ID of the one you want, here Frankfurt, Germany:
CLI: sevalla resources clusters. MCP: get_platform_options.
2

Find the database size

List database resource types (listDatabaseResourceTypesV3) returns the database sizes by name. db1, the smallest, is enough for a blog:
CLI: sevalla resources db-types. MCP: get_platform_options with kind set to database.
3

Create the database

Create database (createDatabaseV3) creates PostgreSQL 18 with a database and a user named storefront, which owns the database. The API doesn’t return the password later, so generate it first and keep it in DB_PASSWORD. A hex password needs no escaping in DB_URL:
CLI: sevalla databases create --name orders-db --type postgresql --db-version 18 --cluster $CLUSTER_ID --resource-type $DB_SIZE_ID --db-name storefront --db-user storefront --db-password $DB_PASSWORD. MCP: create_database, which generates the password itself and never returns it. Given connect_to_application_id once the application exists, it also connects the database and stores a postgresql:// URL in DATABASE_URL, which the template reads before DB_URL: you then skip Connect the database and the DB_ variables.
4

Find the H1 size

List process resource types (listProcessResourceTypesV3) returns the process sizes by name, in lowercase:
CLI: sevalla resources process-types. MCP: get_platform_options.
5

Create the application

Create application (createApplicationV3) creates the application with a web process and auto-deploy off. It builds with Ingot (build_type is ingot) and isn’t deployed yet:
CLI: sevalla apps create --name storefront --cluster $CLUSTER_ID --source privateGit --git-type github --repository https://github.com/acme/storefront --branch main. MCP: create_application with deploy set to false, because it deploys by default.
6

Find the web process

List processes (listProcessesV3) returns the web process that the application was created with:
CLI: sevalla apps processes list --app-id $APP_ID. MCP: get_application.
7

Set the size and the health checks

The API creates the web process at S1, where the dashboard’s default is H1. Update process (updateProcessV3) sets H1 and adds the probes from Add health checks, with the dashboard’s default timings. Send timeoutSeconds: a probe without it gets 120 seconds, where the dashboard sets 1.
CLI: sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID sets the size; the CLI can’t set probes. MCP: scale_process with size_id and apply_now set to false for the size, and sevalla_api_update with updateProcessV3 and the same body for the probes.
8

Create the migration job

Create process (createProcessV3) adds the Migrate job from Run migrations before each deployment. A job needs a size, a scaling strategy and a start policy:
CLI: sevalla apps processes create $APP_ID --name Migrate --type job --command "npm run migrate" --job-start-policy beforeDeployment --resource-type $H1_ID. MCP: sevalla_api_create with createProcessV3 and the same body.
9

Wait for the database

Get database (getDatabaseV3) returns the database’s status, creating until it’s ready, and its internal hostname and port. Poll it every 15 seconds, for up to 30 minutes, until the status is ready or error, then keep the connection values:
CLI: sevalla databases get $DB_ID. MCP: get_database.
10

Connect the database

Create internal connection (createApplicationInternalConnectionV3) connects the application to the database. Unlike the dashboard, it doesn’t add environment variables: the Add the environment variables step adds them.
CLI: sevalla apps internal-connections create $APP_ID --target-type database --target-id $DB_ID. MCP: sevalla_api_create with createApplicationInternalConnectionV3.
11

Find the domain

List application domains (listApplicationDomainsV3) returns the application’s domain, which exists before the first deployment. EMDASH_SITE_URL and the bucket’s CORS rule need it:
CLI: sevalla apps domains list --app-id $APP_ID. MCP: get_application.
12

Create the bucket

Create object storage bucket (createObjectStorageV3) creates the bucket with public access, near Frankfurt (weur, Western Europe). Its response is the only one that has the bucket’s access key and secret key, so keep the whole response in BUCKET:
CLI: sevalla object-storage create --name storefront-media --location weur --json, then sevalla object-storage cdn-domain enable $BUCKET_ID for public access. MCP: sevalla_api_create with createObjectStorageV3.
13

Add the CORS rule

Create CORS policy (createObjectStorageCorsPolicyV3) lets the admin panel upload from the application’s address:
CLI: sevalla object-storage cors-policies create $BUCKET_ID --methods PUT,GET,HEAD --origins https://$HOST --headers content-type. MCP: sevalla_api_create with createObjectStorageCorsPolicyV3.
14

Create the cache purge key

Create API key (createApiKeyV3) creates the key the application purges its edge cache with: APP:UPDATE for this application only. The response is the only one that has the key’s token:
CLI: sevalla api-keys create --name storefront-cache-purge --capabilities APP:UPDATE --json creates the key for every application: the CLI can’t limit a permission to one application. MCP: sevalla_api_create with createApiKeyV3.
15

Add the environment variables

Create environment variable (createEnvironmentVariableV3) adds one variable per call. The DB_ variables have the names and formats that the dashboard’s internal connection adds; every variable is available at runtime only. SEVALLA_API_KEY in the application is the purge key, not your own key:
CLI: sevalla apps env-vars create $APP_ID --key DB_URL --value "...", one call per variable; the CLI makes every variable available during the build and at runtime. MCP: set_environment_variables with runtime set to true and buildtime set to false.
16

Turn on the CDN

Toggle CDN (toggleApplicationCdnV3) switches the CDN on when it’s off, and off when it’s on, then returns the new state. Check that it’s true:
CLI: sevalla apps cdn-toggle $APP_ID. MCP: sevalla_api_create with toggleApplicationCdnV3.
17

Turn on edge caching

Toggle edge cache (toggleApplicationEdgeCacheV3) works the same way for edge caching:
CLI: sevalla apps edge-cache-toggle $APP_ID. MCP: sevalla_api_create with toggleApplicationEdgeCacheV3.
18

Turn on auto-deploy

Update application (updateApplicationV3) turns on automatic deployments for every push to the branch:
CLI: sevalla apps update $APP_ID --data '{"auto_deploy":true}'. MCP: sevalla_api_update with updateApplicationV3.
19

Deploy

Trigger deployment (triggerDeploymentV3) deploys the latest commit on the application’s branch:
CLI: sevalla apps deployments trigger $APP_ID. MCP: trigger_deployment.
20

Wait for the deployment

Get deployment (getDeploymentV3) returns the deployment’s status: waiting and inProgress while it runs, then success or failed. Poll it every 15 seconds, for up to 30 minutes, until the status is final:
CLI: sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID. MCP: get_deployment.
21

Read the build log

Get application deployment logs (getApplicationDeploymentLogsV3) returns the build log, oldest line first: the build plan from Verify the deployment, the build, the Migrate job and the rollout, which ends with Web has been deployed successfully. When the status is failed, the last lines say why. Without limit, the call returns the latest 1,000 lines, so a longer log loses its start; 5000 is the most it allows.
CLI: sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID. MCP: get_deployment_logs.
22

Check the application

The readiness probe’s address answers when the server runs and reaches PostgreSQL:
Then open https://$HOST/_emdash/admin in a browser to run the setup wizard, as in Verify the deployment: registering a passkey needs one.

Build from the Dockerfile with the API

After you push the Dockerfile from Deploy with a Dockerfile, Update application (updateApplicationV3) switches the build strategy. Then repeat the Deploy, Wait for the deployment and Check the application steps:
CLI: sevalla apps update $APP_ID --data '{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}'. MCP: sevalla_api_update with updateApplicationV3.