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

# Run a Socket.IO server

> Run a Socket.IO 4 server for Node.js on Sevalla over WebSockets, then scale it to several instances with WebSocket-only clients and the Redis adapter.

A Socket.IO server runs on Sevalla as the web process of a Node.js application: browsers load the page and the Socket.IO client from the application's domain, and Sevalla's proxy passes their WebSocket connections through to the server on the port in the `PORT` environment variable. This guide builds a small chat, deploys it with Ingot, then runs it on more than one instance with the Redis adapter and a Sevalla Redis or Valkey database.

## 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; WebSockets need no setting |
| Processes | Web process: 1 instance, or 2 with the Redis adapter from [Run more than one instance](#run-more-than-one-instance) |
| Services | With more than one instance: Valkey 9.0 or Redis over an internal connection |
| Disk | None |
| Size | **H1** for the web process (larger for a custom domain), **DB1** for the database |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `REDIS_HOST` | With more than one instance | Runtime | No | Added by the internal connection: the database's internal hostname |
| `REDIS_PORT` | With more than one instance | Runtime | No | Added by the internal connection: the database's internal port |
| `REDIS_PASSWORD` | With more than one instance | Runtime | Yes | Added by the internal connection: the database's password |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

The internal connection also adds `REDIS_URL`, which the server doesn't read: for a Valkey database it starts with `valkey://`, which the `redis` client rejects.

## 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). More than one instance needs the payment method.
* Node.js 24 or later on your computer.

## Create the project

Create a folder and install Socket.IO:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mkdir chat && cd chat
npm init -y
npm install socket.io
```

Create the server. It serves the chat page, and Socket.IO handles every request under `/socket.io/`, including the browser client at `/socket.io/socket.io.js`:

```js server.js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { hostname } from "node:os";
import { Server } from "socket.io";

const page = await readFile(new URL("./index.html", import.meta.url));

const server = createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/html" });
  res.end(page);
});

const io = new Server(server);

io.on("connection", (socket) => {
  console.log(`${socket.id} connected over ${socket.conn.transport.name}`);
  socket.conn.once("upgrade", (transport) => {
    console.log(`${socket.id} upgraded to ${transport.name}`);
  });
  socket.on("chat message", (text) => {
    io.emit("chat message", `${text} (from ${hostname()})`);
  });
});

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

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

* Each message comes back with `os.hostname()`, the name of the instance that relayed it, so you can see which instance a browser talks to.
* The server listens on [`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), on all network interfaces. On your computer, it uses port 3000.
* When Sevalla stops an instance, during a deployment for example, it sends `SIGTERM`. `io.close()` disconnects the clients, which reconnect to a running instance on their own, and closes the HTTP server. See [How processes run](https://docs.sevalla.com/applications/processes.md#how-processes-run).

Create the page:

```html index.html theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Chat</title>
  </head>
  <body>
    <p>Transport: <strong id="transport">connecting</strong></p>
    <ul id="messages"></ul>
    <form id="form">
      <input id="input" autocomplete="off" aria-label="Message">
      <button>Send</button>
    </form>
    <script src="/socket.io/socket.io.js"></script>
    <script>
      const socket = io();
      const transport = document.getElementById("transport");
      const input = document.getElementById("input");

      socket.on("connect", () => {
        transport.textContent = socket.io.engine.transport.name;
        socket.io.engine.once("upgrade", (t) => (transport.textContent = t.name));
      });

      socket.on("chat message", (text) => {
        const item = document.createElement("li");
        item.textContent = text;
        document.getElementById("messages").append(item);
      });

      document.getElementById("form").addEventListener("submit", (event) => {
        event.preventDefault();
        if (input.value) {
          socket.emit("chat message", input.value);
          input.value = "";
        }
      });
    </script>
  </body>
</html>
```

The page shows the transport the connection uses: Socket.IO connects with HTTP long-polling first, then upgrades to a WebSocket.

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` and top-level `await`. |
| `scripts.start` | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), the default build strategy, starts the web process with the `start` script. |
| `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). |

The project needs no build step and no other configuration: WebSockets need no setting on Sevalla. Run `npm start`, open `http://localhost:3000` in two browser tabs and send a message: it appears in both. 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/chat.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 `chat-x7k2m.sevalla.app`, and open it in a second tab too. Each page shows `Transport: websocket`. Send a message: both tabs show it, followed by the instance's name, such as `(from chat-x7k2m-web-6647f5cff4-blnxv)`.

4. On the **Logs** tab, the web process's runtime logs show each connection start on long-polling and move to a WebSocket, such as `l1ivq9ZJh4I-25sEAAAA connected over polling`, then `l1ivq9ZJh4I-25sEAAAA upgraded to websocket`. See [Logs](https://docs.sevalla.com/applications/logs.md).

5. From a terminal, request Socket.IO's handshake:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "https://chat-x7k2m.sevalla.app/socket.io/?EIO=4&transport=polling"
   ```

   The response has a new `sid` each time: `0{"sid":"hDr0l8vCSRvsvTAEAAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}`. With the API, [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) returns the deployment's status and [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) its build log; see [Deploy with the API](#deploy-with-the-api).

![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)

## How WebSocket connections behave on Sevalla

Sevalla's proxy passes WebSocket upgrades through to the web process with no setting to turn on. The [CDN and edge caching](https://docs.sevalla.com/applications/caching.md#what-is-never-cached) never cache WebSocket upgrades, and Socket.IO sends its long-polling responses with `Cache-Control: no-store`, so turning caching on doesn't affect the chat.

* **Idle connections.** The proxies between the browser and the web process close a WebSocket that carries no data for too long, and the client sees close code `1006`. Socket.IO's server sends a ping every 25 seconds (its `pingInterval` default) and the client answers, so a Socket.IO connection never sits idle: a client that sends nothing stays connected. With plain WebSockets, such as the `ws` package, send a ping or a message at least every 30 seconds, as Socket.IO does.
* **One instance per connection.** A WebSocket stays on the instance it opened on until it closes. When the instance stops, during a deployment or a restart, its clients reconnect and land on a running instance.
* **Memory is per instance.** Rooms, the list of connected sockets and anything else Socket.IO keeps in memory belong to one instance and are gone when it stops.

## Run more than one instance

Sevalla spreads the requests to a web process across its instances without session affinity (sticky sessions): two requests from the same browser can reach different instances. With more than one instance, that breaks two things in Socket.IO:

| What breaks | What you see | Fix |
| - | - | - |
| Long-polling. Each poll is a new HTTP request, and an instance knows only the sessions it created. | Polling requests to `/socket.io/` fail with `400` and `{"code":1,"message":"Session ID unknown"}`. Clients disconnect with `transport error` and reconnect over and over. | Connect over WebSocket only. A WebSocket is one long-lived connection, so it stays on one instance. |
| Broadcasts. `io.emit()` reaches only the clients connected to the instance that runs it. | A message reaches only the tabs connected to the same instance as its sender. | The Redis adapter, which passes events between instances through a Redis or Valkey database. |

### Connect over WebSocket only

In `index.html`, replace `const socket = io();` with:

```js index.html theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
const socket = io({ transports: ["websocket"] });
```

The client opens a WebSocket right away, without the long-polling step. A browser on a network that blocks WebSockets can't fall back to long-polling, so it doesn't connect.

### Share events between instances

**1. Create the database**

In the sidebar, click **Databases**, then click **Create database**. Under **Database**, choose **Valkey** or **Redis** and the newest version. Enter `chat-valkey` as the **Display name**, choose the application's **Data center** and a **Size**, then click **Create database**. See [Create a database](https://docs.sevalla.com/databases/create.md).

**2. Connect the database**

On the application's **Networking** tab, under **Connected services**, click **Add internal connection**. Choose `chat-valkey` as the **Service**, turn on **Add environment variables to the application**, and click **Add internal connection**. Sevalla adds `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` and `REDIS_URL` to the application. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

**3. Install the adapter**

Install the Redis adapter and the `redis` client:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm install @socket.io/redis-adapter redis
```

**4. Use the adapter in the server**

Replace `server.js`:

```js server.js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { hostname } from "node:os";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";

const page = await readFile(new URL("./index.html", import.meta.url));

const server = createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/html" });
  res.end(page);
});

const io = new Server(server);

if (process.env.REDIS_HOST) {
  const pubClient = createClient({
    socket: { host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT) },
    password: process.env.REDIS_PASSWORD,
  });
  const subClient = pubClient.duplicate();
  pubClient.on("error", (err) => console.error("Redis error:", err.message));
  subClient.on("error", (err) => console.error("Redis error:", err.message));
  await Promise.all([pubClient.connect(), subClient.connect()]);
  io.adapter(createAdapter(pubClient, subClient));
}

io.on("connection", (socket) => {
  console.log(`${socket.id} connected over ${socket.conn.transport.name}`);
  socket.conn.once("upgrade", (transport) => {
    console.log(`${socket.id} upgraded to ${transport.name}`);
  });
  socket.on("chat message", (text) => {
    io.emit("chat message", `${text} (from ${hostname()})`);
  });
});

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

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

The server connects to the database only when `REDIS_HOST` is set, so `npm start` still works on your computer without one. It reads the host, port and password, not `REDIS_URL`: the `redis` client accepts only `redis://` and `rediss://` URLs, and a Valkey database's `REDIS_URL` starts with `valkey://`. The same code works with a Redis database. The `error` listeners log a lost connection while the client reconnects, instead of stopping the server.

### Scale the web process

Commit and push the changes, then run two instances:

**1. Set the instance count**

On the application's **Processes** tab, click **⋯** on the web process, then click **Update process**. In **Scaling**, leave **Horizontal auto-scaling** off, drag **Instance count** to `2`, then click **Update process**. See [Set the number of instances](https://docs.sevalla.com/applications/scaling.md#set-the-number-of-instances).

**2. Deploy**

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

When the deployment is **Deployed**, open the domain in several tabs. Each one shows `Transport: websocket` from the start. Send a message from each tab: every tab shows every message, and the instance names after them show that the tabs are connected to both instances. The runtime logs show `connected over websocket`, with no upgrade.

To check from a terminal, connect 6 clients in a separate folder and send a message from each:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mkdir chat-check && cd chat-check
npm init -y
npm install socket.io-client
```

```js chat-check.mjs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { io } from "socket.io-client";

const url = process.argv[2];
const clients = Array.from({ length: 6 }, () => io(url, { transports: ["websocket"] }));
await Promise.all(clients.map((c) => new Promise((resolve) => c.once("connect", resolve))));

const received = clients.map(() => new Set());
clients.forEach((c, i) => c.on("chat message", (text) => received[i].add(text)));
clients.forEach((c, i) => c.emit("chat message", `message ${i}`));
await new Promise((resolve) => setTimeout(resolve, 2000));

const complete = received.filter((r) => r.size === clients.length).length;
const instances = new Set([...received[0]].map((text) => text.match(/\(from (.+)\)/)[1]));
console.log(`${complete} of ${clients.length} clients got all ${clients.length} messages`);
console.log(`Relayed by ${instances.size} instances: ${[...instances].sort().join(", ")}`);
clients.forEach((c) => c.close());
```

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
node chat-check.mjs https://chat-x7k2m.sevalla.app
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
6 of 6 clients got all 6 messages
Relayed by 2 instances: chat-x7k2m-web-b9f594b64-d9cs8, chat-x7k2m-web-b9f594b64-s9s8w
```

Each message names the instance its sender is connected to, so 2 instances means the clients are spread over both, and every client getting every message means the adapter passes them between instances. The clients land on instances at random: if the output names 1 instance, which happens about 1 time in 32, run it again.

## 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 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:24-trixie-slim
# hadolint ignore=DL3008
RUN apt-get update \
  && apt-get install -y --no-install-recommends tini \
  && rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json server.js index.html ./
USER 1000:1000
EXPOSE 8080
ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]
```

| Line or choice | Why |
| - | - |
| `node:24-trixie-slim` | The Node.js 24 line that `engines.node` pins, on Debian 13, the image Ingot runs too. A line tag, not a digest, so each build picks up Node.js and Debian security fixes. |
| Two stages | The `deps` stage installs the production dependencies from `package-lock.json`. The final stage copies `node_modules` without npm's download cache. |
| `package.json` and `package-lock.json` copied before the source | A change to `server.js` or `index.html` reuses the cached `npm ci` layer. |
| `tini` | Runs as PID 1 and passes `SIGTERM` on to Node.js, so the server's `SIGTERM` handler runs. It comes from Debian's packages, unpinned so that rebuilds get its fixes, which is why the line before it tells hadolint to skip its version-pinning check. |
| `USER 1000:1000` | The image's `node` user, not root. The copied files stay owned by root, so the server can read its code but not change it. |
| `EXPOSE 8080` | Documents Sevalla's default `PORT`. The server reads `PORT` when it starts. |

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
node_modules
.env*
*.log
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: its build plan then shows `Ignored 6 patterns from .dockerignore`, and the Ingot build still passes because none of these patterns removes a file it needs. 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 deployment's build log shows `📦 Building and pushing docker image using Dockerfile...` instead of Ingot's build plan, and its **Dockerfile** tab shows your file. The instance count and the database connection stay as they are. When the deployment's status is **Deployed**, the chat works as before, over WebSockets on both instances. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md).

## Deploy changes

With **Auto-deploy** on, every push to the branch starts a deployment; otherwise, click **Deploy now** for each change. Each deployment starts new instances, sends them traffic, then stops the old ones: their clients disconnect and reconnect to a new instance. Keep anything that must outlive a connection, such as chat history, in a database, not in the server's memory. 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` and the `REDIS_*` variables. Variables apply at the next deployment. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

## Add a custom domain

To serve the chat from your own domain, such as `chat.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).

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| Clients connect and disconnect in a loop, and requests to `/socket.io/?EIO=4&transport=polling` return `400` with `{"code":1,"message":"Session ID unknown"}` | The web process runs more than one instance, and the client starts with long-polling | Connect with `io({ transports: ["websocket"] })`, as in [Connect over WebSocket only](#connect-over-websocket-only). |
| A message reaches only some of the open tabs | More than one instance, without the Redis adapter | Add the adapter, as in [Share events between instances](#share-events-between-instances). |
| The web process stops at start with `Protocol - valkey: - is not a valid Redis protocol. Expected redis: or rediss:` | The `redis` client got the `REDIS_URL` of a Valkey database | Pass `REDIS_HOST`, `REDIS_PORT` and `REDIS_PASSWORD`, as `server.js` does. |
| Plain WebSocket connections close with code `1006` when no messages are sent | A proxy closed the idle connection | Send a ping or a message at least every 30 seconds, as Socket.IO's heartbeat does every 25 seconds. |

## Deploy with the API

This sequence deploys the finished project from [Run more than one instance](#run-more-than-one-instance), with the Redis adapter, a Valkey database and two instances, through the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md), so an agent or a script can run it as is. Each step names the operation, shows the `curl` command, and gives the matching [CLI](https://docs.sevalla.com/developer-tools/cli.md) command and [MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool.

You need:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, in the `SEVALLA_API_KEY` environment variable. The CLI reads the same key from `SEVALLA_API_TOKEN`.
* `curl`, `jq` and `openssl`, and the project pushed to GitHub as in [Prepare the project for Sevalla](#prepare-the-project-for-sevalla).
* A GitHub account that can read the repository, connected to Sevalla. See [Git providers](https://docs.sevalla.com/git/overview.md). The API can't list connected accounts: when none can read the repository, [Create application](https://api-docs.sevalla.com/v3/applications/create-application) fails with `400` and `Cannot access repository https://github.com/acme/chat: Repository not found or you don't have access.`

A call that fails returns `status` and `message` instead of the resource, and `jq -r .id` prints `null`: stop and read the message.

**1. Look up the location and the sizes**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns every location your company can use, with its region ID. This guide uses Frankfurt, Germany, `eu-frankfurt-1`. [List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) and [List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`) return the sizes.

```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')
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')
DB_SIZE_ID=$(curl -s https://api.sevalla.com/v3/resources/database-resource-types \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[] | select(.name == "db1") | .id')
```

CLI: `sevalla resources clusters`, `sevalla resources process-types` and `sevalla resources db-types`. MCP: `get_platform_options`.

**2. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates the application with a web process and doesn't deploy it.

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

CLI: `sevalla apps create --name chat --cluster "$CLUSTER_ID" --source privateGit --git-type github --repository https://github.com/acme/chat --branch main`. MCP: `create_application` with `deploy` set to `false`.

**3. Size and scale the web process**

The API creates the web process at **S1**, while the dashboard creates it at **H1**, which is enough for the chat. [List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) returns the web process, and [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets its size and 2 instances. To add a custom domain later, leave `resource_type_id` out and keep **S1**.

```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')
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\", \"scaling_strategy\": {\"type\": \"manual\", \"config\": {\"instanceCount\": 2}}}" | jq -c '{resource_type_name, scaling_strategy}'
```

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

**4. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) starts creating a Valkey 9.0 database with a password you generate. [List database versions](https://api-docs.sevalla.com/v3/resources/list-database-versions) (`listDatabaseVersionsV3`) lists the versions of a location; a Redis database works the same way with `"type": "redis"`.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_PASSWORD=$(openssl rand -hex 24)
DB_ID=$(curl -s https://api.sevalla.com/v3/databases \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<JSON | jq -r .id
{
  "display_name": "chat-valkey",
  "type": "valkey",
  "version": "9.0",
  "cluster_id": "$CLUSTER_ID",
  "resource_type_id": "$DB_SIZE_ID",
  "db_name": "chat",
  "db_password": "$DB_PASSWORD"
}
JSON
)
```

CLI: `sevalla databases create --name chat-valkey --type valkey --db-version 9.0 --cluster "$CLUSTER_ID" --resource-type "$DB_SIZE_ID" --db-name chat --db-password "$DB_PASSWORD"`. MCP: `create_database`, which generates a password and doesn't return it; set one you know afterwards with `sevalla_api_update`, `resetDatabasePasswordV3` and `{"new_password": "..."}`.

**5. Connect the database**

[Create internal connection](https://api-docs.sevalla.com/v3/databases/create-internal-connection) (`createDatabaseInternalConnectionV3`) connects the database to the application over the private network. Unlike the dashboard, it adds no environment variables: a later step adds them.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://api.sevalla.com/v3/databases/$DB_ID/internal-connections \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"target_id\": \"$APP_ID\", \"target_type\": \"app\"}" | jq .
```

CLI: `sevalla databases connections internal-create "$DB_ID" --target-id "$APP_ID" --target-type app`. MCP: `sevalla_api_create` with `createDatabaseInternalConnectionV3`.

**6. Read the database details**

[Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) returns the database's internal hostname and port. The loop waits until its `status` is `ready`.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 60); do
  [ "$(curl -s https://api.sevalla.com/v3/databases/$DB_ID -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.status // empty')" = ready ] && break
  sleep 10
done
DB=$(curl -s https://api.sevalla.com/v3/databases/$DB_ID -H "Authorization: Bearer $SEVALLA_API_KEY")
```

CLI: `sevalla databases get "$DB_ID" --json`. MCP: `get_database`.

**7. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`) adds one variable per call. `add_var` sends each one as available during runtime only. These are the variables the dashboard's internal connection adds, except `REDIS_URL`, which the code doesn't read:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
add_var() {
  curl -s https://api.sevalla.com/v3/applications/$APP_ID/env-vars \
    -H "Authorization: Bearer $SEVALLA_API_KEY" \
    -H "Content-Type: application/json" \
    -d "$(jq -n --arg key "$1" --arg value "$2" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}')" | jq -r .key
}
add_var REDIS_HOST "$(jq -r .internal_hostname <<< "$DB")"
add_var REDIS_PORT "$(jq -r .internal_port <<< "$DB")"
add_var REDIS_PASSWORD "$DB_PASSWORD"
```

CLI: `sevalla apps env-vars create "$APP_ID" --key REDIS_HOST --value "$(jq -r .internal_hostname <<< "$DB")"`, once per variable; the CLI makes each one available during the build too. MCP: `set_environment_variables` with the three variables in one call, each with `runtime` `true` and `buildtime` `false`.

**8. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) with `auto_deploy` deploys 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`.

**9. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) builds the latest commit of the branch and rolls it out.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s 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`.

**10. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns the deployment's `status`: `waiting`, then `inProgress`, then `success` or `failed`. The loop polls every 15 seconds for up to 30 minutes and keeps polling through a failed request.

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

**11. Check the deployment**

[Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) returns the build log, which starts with the build plan in [Verify the deployment](#verify-the-deployment) and, on a failed deployment, ends with the error. [List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the application's domain:

```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'
APP_URL=https://$(curl -s https://api.sevalla.com/v3/applications/$APP_ID/domains \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.data[] | select(.is_primary) | .name')
```

Then run the checks in [Verify the deployment](#verify-the-deployment) and [Scale the web process](#scale-the-web-process) with `$APP_URL` as the domain.

CLI: `sevalla apps deployments logs "$DEPLOYMENT_ID" --app-id "$APP_ID"` and `sevalla apps domains list --app-id "$APP_ID"`. MCP: `get_deployment_logs` and `get_application`.


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