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

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Deploy a Koa application

> Deploy a Koa 3 server with @koa/router to Sevalla from a Git repository: read the PORT variable, trust the proxy headers, pin Node.js 24, and build with Ingot or a Dockerfile.

A Koa server runs on Sevalla as an application: Ingot, Sevalla's builder, installs the dependencies from your Git repository with `npm ci`, and the web process runs the `start` script, `node server.js`, which listens on the port that Sevalla sets in the `PORT` environment variable.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Node.js 24, npm), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `node server.js`, from `scripts.start` in `package.json` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process |
| Services | None |
| Disk | None |
| Size | **H1** for the web process |

### Environment variables

The application needs no environment variables of its own. Sevalla sets `PORT` to the web process's port.

## Before you begin

* A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See [Git providers](https://docs.sevalla.com/git/overview.md).
* Node.js 24 or later on your computer.

## Create the project

Create a folder and install Koa and its router:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mkdir storefront && cd storefront
npm init -y
npm install koa @koa/router
```

Create the server:

```js server.js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import Koa from "koa";
import Router from "@koa/router";

// Trust the X-Forwarded-* headers from Sevalla's load balancer
const app = new Koa({ proxy: true });
const router = new Router();

router.get("/", (ctx) => {
  ctx.body = `Hello from Koa on Sevalla over ${ctx.protocol}`;
});

app.use(router.routes()).use(router.allowedMethods());

const port = process.env.PORT || 3000;
const server = app.listen(port, () => {
  console.log(`Listening on port ${port}`);
});

process.on("SIGTERM", () => {
  console.log("SIGTERM received, closing the server");
  server.close();
});
```

Keep installed packages out of the repository:

```text .gitignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
node_modules/
```

## Prepare the project for Sevalla

Set the module type, the start script and the Node.js version in `package.json`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm pkg set type=module scripts.start="node server.js" engines.node="24.x"
```

| Field | Why Sevalla needs it |
| - | - |
| `"type": "module"` | Lets `server.js` use `import`. `npm init -y` writes `"type": "commonjs"`, which makes Node.js stop at the first `import`. |
| `scripts.start` | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), the default build strategy, starts the web process with the `start` script. Without one, it runs the file in `main`, which `npm init` sets to `index.js`, a file this project doesn't have. |
| `engines.node` | Ingot builds on the Node.js 24 line. Without it, Ingot uses its default line, which a new Ingot release can move. See [Pin the Node.js version](https://docs.sevalla.com/guides/javascript/overview.md#pin-the-node-js-version). |

`server.js` already handles the port, the proxy and shutdown:

* It reads [`PORT`](https://docs.sevalla.com/guides/javascript/overview#listen-on-the-port-environment-variable), which Sevalla sets to the web process's port (`8080` by default), and `app.listen(port)` listens on all network interfaces.
* Sevalla's load balancer ends HTTPS and forwards each request to the instance over HTTP, with the original protocol and client address in the `X-Forwarded-Proto` and `X-Forwarded-For` [headers](https://docs.sevalla.com/applications/http-headers.md). `proxy: true` makes Koa read them, so `ctx.protocol` is `https`, `ctx.secure` is `true` and `ctx.ip` is the visitor's address. Without it, Koa sees every request as `http`, and setting a cookie with `secure: true` fails.
* Sevalla sends `SIGTERM` to each instance it replaces during a deployment. `server.close()` stops accepting connections and lets the requests in progress finish, then Node.js exits.

Koa needs no build step, so the project has no `build` script, and Ingot needs no other configuration.

Run `npm start` and open `http://localhost:3000` to check the server locally: it shows `Hello from Koa on Sevalla over http`. Then commit everything and push it to your Git provider:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
git init
git add .
git commit -m "Prepare for Sevalla"
git branch -M main
git remote add origin https://github.com/acme/storefront.git
git push -u origin main
```

## Deploy to Sevalla

These steps build with [Ingot](https://docs.sevalla.com/applications/builds/ingot.md). To build from your own Dockerfile instead, see [Deploy with a Dockerfile](#deploy-with-a-dockerfile).

**1. 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 a **Data center**. 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.

**2. Deploy**

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

**3. 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](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push).

## Verify the deployment

1. On the **Deployments** tab, click the deployment. Its **Build log** shows Ingot's [build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan): Node.js 24 from `engines.node`, npm from `package-lock.json` and the start command from the `start` script.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Node.js 24 application ───────────────────────────────────╮
   │ Node.js          24              package.json engines "24.x" │
   │ Package manager  npm             package-lock.json           │
   │ Start            node server.js  package.json scripts.start  │
   ├─ Image ──────────────────────────────────────────────────────┤
   │ Build            node:24-trixie                              │
   │ Runtime          node:24-trixie-slim                         │
   │ User             1000:1000                                   │
   │ Init             tini                                        │
   │ Port             $PORT, default 8080                         │
   ╰─────────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

2. Wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**.

3. Click the domain under the application's name, for example `storefront-x7k2m.sevalla.app`. The page shows `Hello from Koa on Sevalla over https`: Koa read the protocol from the load balancer's `X-Forwarded-Proto` header.

4. Check the same address from a terminal:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://storefront-x7k2m.sevalla.app/
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Hello from Koa on Sevalla over https
   ```

![The application header with its name, Running status, repository, branch and the domain that opens the application](https://assets.sevalla.com/screenshots/light/app-overview-address.png)

With the API, [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns the deployment's `status`, `success` once it's deployed, and [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) returns its build log, which ends with `Web has been deployed successfully.` See [Deploy with the API](#deploy-with-the-api).

If the deployment fails, read its build log, then see [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds). If it deploys but the domain doesn't respond, check that the server reads `PORT` and read the application's [runtime logs](https://docs.sevalla.com/applications/logs.md). When the next deployment replaces an instance, the runtime logs show `SIGTERM received, closing the server`.

## 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. Add a `Dockerfile` and a `.dockerignore` to the root of the repository:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FROM node:24-trixie-slim

WORKDIR /app
ENV NODE_ENV=production PORT=8080

COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY . .

USER 1000:1000
EXPOSE 8080
CMD ["node", "server.js"]
```

| Line or choice | Why |
| - | - |
| `node:24-trixie-slim` | The official Node.js 24 image on Debian 13, the same line that `engines.node` pins for Ingot. The tag follows Node.js 24's patch releases, so each build gets its security fixes; a digest would stay on one release. |
| `package.json` and `package-lock.json` first | `npm ci --omit=dev` installs the exact versions in the lock file, without devDependencies. Copying the two files before the code lets Docker reuse the installed dependencies when only the code changes. |
| `USER 1000:1000` | The server runs as the image's unprivileged `node` user. The code and dependencies stay owned by root, so the server can read them but not change them. |
| No init process | `server.js` handles `SIGTERM` itself, so Node.js shuts down cleanly as the container's first process. |
| `PORT=8080` and `EXPOSE 8080` | `server.js` listens on `PORT`, which Sevalla sets at runtime. The image's default matches Sevalla's, for running it on your computer. |

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
node_modules
npm-debug.log*
.env*
Dockerfile
.dockerignore
```

Commit and push both files. Ingot doesn't use the `Dockerfile`, so you can keep it in a repository that builds with Ingot, but it adds the `.dockerignore` patterns to its own, and its build plan lists them in an `Ignored` row. 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 credential](https://assets.sevalla.com/screenshots/light/app-settings-build-strategy-dockerfile.png)

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**, the domain shows `Hello from Koa on Sevalla over https` again, and `curl -s https://storefront-x7k2m.sevalla.app/` prints the same line. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md) for build arguments and private base images, and [Deploy with the API](#build-from-the-dockerfile-with-the-api) to switch the build strategy with the API.

## 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](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

Add configuration on the application's **Environment variables** tab and read it with `process.env`, as the server reads `PORT`. Variables marked **Available during buildtime** also reach `npm ci`; with a Dockerfile, they arrive as build arguments, which a stage reads only after an `ARG` line for each one. Variables apply at the next deployment. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

## Add a custom domain

To serve the application from your own domain, such as `shop.example.com`, add the domain on the application's **Domains** tab and create the DNS records that Sevalla shows. Custom domains need a web process larger than **H1**, so [scale](https://docs.sevalla.com/applications/scaling.md) the web process first. See [Custom domains](https://docs.sevalla.com/domains/overview.md).

## Connect a database

Create a [database](https://docs.sevalla.com/databases/create.md) in the same location as the application, then [connect the application to it](https://docs.sevalla.com/databases/connect.md#connect-an-application) with **Add environment variables to the application** turned on. Sevalla adds `DB_URL`, `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME` and `DB_PASSWORD` to the application ([what each one holds](https://docs.sevalla.com/databases/connect.md#environment-variables-an-internal-connection-adds)), and they apply at the next deployment. Read them in the server with `process.env.DB_URL`.

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| The web process stops at start with `SyntaxError: Cannot use import statement outside a module` | `package.json` has `"type": "commonjs"`, which `npm init -y` writes | Run `npm pkg set type=module`, then commit and push `package.json`. |
| A route returns `500 Internal Server Error`, and the runtime logs show `Error: Cannot send secure cookie over unencrypted connection` | The route sets a cookie with `secure: true`, and Koa doesn't trust the proxy headers, so it sees the request as HTTP | Create the app with `new Koa({ proxy: true })`, as `server.js` does. |

For more causes, see [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds) and [Troubleshoot builds and deployments](https://docs.sevalla.com/applications/troubleshooting/builds-and-deployments.md).

## Deploy with the API

These calls make the same deployment as [Deploy to Sevalla](#deploy-to-sevalla), in an order that a script or an AI agent runs as it is. Each step names the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) operation, then the [CLI](https://docs.sevalla.com/developer-tools/cli.md) command and the [MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool that do the same. You need:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, or the `APP:CREATE`, `APP:READ` and `APP:UPDATE` permissions, in the `SEVALLA_API_KEY` environment variable. The CLI reads the key from `SEVALLA_API_TOKEN`.
* The repository on a Git provider that's connected to your Sevalla account. See [Git providers](https://docs.sevalla.com/git/overview.md). 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.
* `curl` and `jq`.

**1. Choose the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/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](https://docs.sevalla.com/platform/locations.md)). Keep the ID of the one you want, here Frankfurt, Germany:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CLUSTER_ID=$(curl -s https://api.sevalla.com/v3/resources/clusters \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.location == "eu-frankfurt-1") | .id')
```

CLI: `sevalla resources clusters`. MCP: `get_platform_options`.

**2. Find the H1 size**

[List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) returns the sizes by `name`, in lowercase:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
H1_ID=$(curl -s https://api.sevalla.com/v3/resources/process-resource-types \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.name == "h1") | .id')
```

CLI: `sevalla resources process-types`. MCP: `get_platform_options`.

**3. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/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:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(curl -s -X POST https://api.sevalla.com/v3/applications \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"display_name":"storefront","cluster_id":"'"$CLUSTER_ID"'","source":"privateGit","git_type":"github","repo_url":"https://github.com/acme/storefront","default_branch":"main"}' \
  | jq -r .id)
```

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.

**4. Find the web process**

[List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) returns the web process that the application was created with:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID=$(curl -s https://api.sevalla.com/v3/applications/$APP_ID/processes \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.data[] | select(.type == "web") | .id')
```

CLI: `sevalla apps processes list --app-id $APP_ID`. MCP: `get_application`.

**5. Set the size**

The API creates the web process at **S1**, where the dashboard's default is **H1**. [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets **H1**:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH https://api.sevalla.com/v3/applications/$APP_ID/processes/$WEB_ID \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"resource_type_id":"'"$H1_ID"'"}' \
  | jq -c '{resource_type_name, cpu_limit, memory_limit}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"resource_type_name":"h1","cpu_limit":300,"memory_limit":300}
```

CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID`. MCP: `scale_process` with `size_id` and `apply_now` set to `false`.

**6. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) turns on automatic deployments for every push to the branch:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH https://api.sevalla.com/v3/applications/$APP_ID \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"auto_deploy":true}' | jq .auto_deploy
```

CLI: `sevalla apps update $APP_ID --data '{"auto_deploy":true}'`. MCP: `sevalla_api_update` with `updateApplicationV3`.

**7. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) deploys the latest commit on the application's branch:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST https://api.sevalla.com/v3/applications/$APP_ID/deployments \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}' | jq -r .id)
```

CLI: `sevalla apps deployments trigger $APP_ID`. MCP: `trigger_deployment`.

**8. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/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:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  STATUS=$(curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.status // empty')
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

CLI: `sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment`.

**9. Read the build log**

[Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) returns the build log, oldest line first: the build plan from [Verify the deployment](#verify-the-deployment), the build 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.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?limit=5000" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'
```

CLI: `sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment_logs`.

**10. Check the application**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the application's domain. Request the home page from it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
HOST=$(curl -s https://api.sevalla.com/v3/applications/$APP_ID/domains \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.data[] | select(.is_primary) | .name')
curl -s https://$HOST/
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
Hello from Koa on Sevalla over https
```

CLI: `sevalla apps domains list --app-id $APP_ID`. MCP: `get_application`.

### Build from the Dockerfile with the API

After you push the `Dockerfile` from [Deploy with a Dockerfile](#deploy-with-a-dockerfile), [Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) switches the build strategy. Then repeat the **Deploy**, **Wait for the deployment** and **Check the application** steps:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH https://api.sevalla.com/v3/applications/$APP_ID \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}' \
  | jq -c '{build_type, dockerfile_path, docker_context}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}
```

CLI: `sevalla apps update $APP_ID --data '{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}'`. MCP: `sevalla_api_update` with `updateApplicationV3`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.