> ## 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 an AI chatbot with the AI SDK

> Deploy a Next.js chatbot built with the AI SDK to Sevalla: stream Claude's answers to the browser, call a tool, save chats in PostgreSQL and require a password.

An AI SDK chatbot runs on Sevalla as a Next.js application: the browser's `useChat` hook posts each message to a route handler, the route handler streams the model's answer back as server-sent events, and every conversation is saved in a Sevalla PostgreSQL database. [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) builds the Next.js standalone server and starts the web process with `node server.js`, and a job creates the database table before each deployment. This guide covers what a chatbot adds to a Next.js application; for the Next.js basics, see [Deploy a Next.js application](https://docs.sevalla.com/guides/javascript/nextjs.md).

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Node.js 24, npm, Next.js standalone output), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `node server.js`, the Next.js standalone server, which Ingot detects from `output: "standalone"` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`node scripts/migrate.mjs`, **Before deployment**) |
| Services | PostgreSQL 18 over an internal connection |
| Disk | None |
| Size | **H1** for the web process and the job |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DB_URL` | Yes | Runtime | Yes | Added by the internal connection: `postgres://<user>:<password>@<host>:5432/<database>` |
| `ANTHROPIC_API_KEY` | Yes | Runtime | Yes | Your Anthropic API key |
| `CHAT_USERNAME` | Yes | Runtime | No | The user name people sign in with, for example `acme` |
| `CHAT_PASSWORD` | Yes | Runtime | Yes | Generate with `node -e "console.log(crypto.randomUUID())"` |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process 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.
* An Anthropic API key from the [Claude Console](https://platform.claude.com/settings/keys). Set a spending limit on the account: anyone who can use the chatbot spends from it.

## Create the project

Create a Next.js project with the default options, then add the AI SDK, its React hooks, the Anthropic provider, Zod and the PostgreSQL client:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npx create-next-app@latest support-chat --yes
cd support-chat
npm install ai @ai-sdk/react @ai-sdk/anthropic zod pg
npm install -D @types/pg
```

| Package | What it does |
| - | - |
| `ai` | The AI SDK 7: `streamText`, which calls the model and streams its answer, `tool` and the message helpers |
| `@ai-sdk/react` | The `useChat` hook that sends messages and renders the stream in the browser |
| `@ai-sdk/anthropic` | Connects the AI SDK to Claude with the `ANTHROPIC_API_KEY` environment variable |
| `zod` | Describes the tool's input, which the model must follow |
| `pg` | Saves and loads conversations in PostgreSQL |

### Save chats in PostgreSQL

The chatbot keeps each conversation as one row: its ID and its messages, in the format `useChat` uses. Create the file that loads and saves them, using the `DB_URL` variable that Sevalla adds when you connect the database:

```ts lib/chat-store.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import type { UIMessage } from "ai";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DB_URL });

export async function loadChat(id: string): Promise<UIMessage[]> {
  const { rows } = await pool.query("SELECT messages FROM chats WHERE id = $1", [id]);
  return rows[0]?.messages ?? [];
}

export async function saveChat(id: string, messages: UIMessage[]) {
  await pool.query(
    `INSERT INTO chats (id, messages) VALUES ($1, $2)
     ON CONFLICT (id) DO UPDATE SET messages = $2, updated_at = now()`,
    [id, JSON.stringify(messages)],
  );
}
```

Create the script that creates the table. A job runs it before each deployment, and it changes nothing when the table exists:

```js scripts/migrate.mjs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import pg from "pg";

const client = new pg.Client({ connectionString: process.env.DB_URL });
await client.connect();
await client.query(`
  CREATE TABLE IF NOT EXISTS chats (
    id text PRIMARY KEY,
    messages jsonb NOT NULL,
    updated_at timestamptz NOT NULL DEFAULT now()
  )
`);
await client.end();
console.log("The chats table is ready");
```

### Stream answers with a tool

Create the route handler that `useChat` posts to:

```ts app/api/chat/route.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { anthropic } from "@ai-sdk/anthropic";
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  generateId,
  isStepCount,
  streamText,
  toUIMessageStream,
  tool,
  validateUIMessages,
  type UIMessage,
} from "ai";
import { z } from "zod";
import { loadChat, saveChat } from "@/lib/chat-store";

const tools = {
  getTime: tool({
    description: "Get the current date and time in an IANA time zone, such as Europe/Berlin",
    inputSchema: z.object({ timeZone: z.string() }),
    execute: async ({ timeZone }) =>
      new Date().toLocaleString("en-US", { timeZone, dateStyle: "full", timeStyle: "short" }),
  }),
};

export async function POST(req: Request) {
  const { id, message }: { id: string; message: UIMessage } = await req.json();
  const messages = await validateUIMessages({
    messages: [...(await loadChat(id)), { ...message, role: "user" }],
    tools,
  });

  const result = streamText({
    model: anthropic("claude-haiku-5-5"),
    system: "You are a concise assistant. Use the getTime tool for questions about the current date or time.",
    messages: await convertToModelMessages(messages),
    tools,
    stopWhen: isStepCount(5),
  });
  result.consumeStream(); // finish and save the reply even if the browser disconnects

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({
      stream: result.stream,
      originalMessages: messages,
      generateMessageId: generateId,
      onEnd: ({ messages }) => saveChat(id, messages),
    }),
  });
}
```

* The browser sends only the new message. The route loads the history from the database and treats the new message as the user's, so a visitor can't rewrite earlier answers to steer the model.
* `getTime` runs on the server. The model calls it when it needs the time, the route sends the result back to the model, and `stopWhen: isStepCount(5)` allows up to 5 model calls per message, enough for a tool call and the answer that uses it.
* `consumeStream()` reads the model's answer to the end even when the browser closes the tab, so `onEnd` still saves it.
* `createUIMessageStreamResponse` streams the answer as server-sent events with the headers that keep proxies from buffering it: `Content-Type: text/event-stream`, `Cache-Control: no-cache` and `X-Accel-Buffering: no`.
* The model is Claude Haiku 5.5 (`claude-haiku-5-5`), the least expensive current Claude model. See [Change the model or provider](#change-the-model-or-provider).

### Show the chat

Replace the starter page with a page per conversation. Delete `app/page.tsx`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
rm app/page.tsx
```

Create the page, which loads the conversation from the database:

```tsx app/chat/[id]/page.tsx theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { Suspense } from "react";
import { loadChat } from "@/lib/chat-store";
import { Chat } from "./chat";

export default function Page({ params }: PageProps<"/chat/[id]">) {
  return (
    <Suspense>
      <ChatWithHistory params={params} />
    </Suspense>
  );
}

async function ChatWithHistory({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return <Chat id={id} initialMessages={await loadChat(id)} />;
}
```

The project has Cache Components turned on, so the page reads the database inside `<Suspense>`: Next.js sends the page's static part first and streams the conversation into it.

Create the chat component, which runs in the browser:

```tsx app/chat/[id]/chat.tsx theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import { useState } from "react";

export function Chat({ id, initialMessages }: { id: string; initialMessages: UIMessage[] }) {
  const [input, setInput] = useState("");
  const { messages, sendMessage, status, error } = useChat({
    id,
    messages: initialMessages,
    transport: new DefaultChatTransport({
      api: "/api/chat",
      prepareSendMessagesRequest: ({ id, messages }) => ({
        body: { id, message: messages.at(-1) },
      }),
    }),
  });
  const busy = status === "submitted" || status === "streaming";

  return (
    <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-4 p-6">
      {messages.map((message) => (
        <div key={message.id} className={message.role === "user" ? "self-end rounded-lg bg-zinc-100 px-3 py-2 dark:bg-zinc-800" : ""}>
          {message.parts.map((part, i) =>
            part.type === "text" ? (
              <p key={i} className="whitespace-pre-wrap">{part.text}</p>
            ) : part.type.startsWith("tool-") ? (
              <p key={i} className="text-sm text-zinc-500">Called {part.type.slice(5)}</p>
            ) : null,
          )}
        </div>
      ))}
      {error && <p className="text-red-600">{error.message}</p>}
      <form
        className="mt-auto flex gap-2"
        onSubmit={(e) => {
          e.preventDefault();
          if (!input.trim()) return;
          sendMessage({ text: input });
          setInput("");
        }}
      >
        <input
          className="flex-1 rounded border px-3 py-2"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Ask something"
        />
        <button className="rounded bg-black px-4 py-2 text-white disabled:opacity-50" disabled={busy}>
          Send
        </button>
      </form>
    </main>
  );
}
```

`prepareSendMessagesRequest` sends the chat ID and the new message only. The component shows each text part as it streams in, and a `Called getTime` line where the model used the tool.

### Require a password

Every message spends money on your Anthropic account, so the chatbot asks for a user name and password before it shows anything. Create `proxy.ts` in the root of the project. Next.js runs it before every request:

```ts proxy.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { timingSafeEqual } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const { CHAT_USERNAME, CHAT_PASSWORD } = process.env;
  const expected = Buffer.from(`Basic ${Buffer.from(`${CHAT_USERNAME}:${CHAT_PASSWORD}`).toString("base64")}`);
  const given = Buffer.from(request.headers.get("authorization") ?? "");
  if (!CHAT_PASSWORD || given.length !== expected.length || !timingSafeEqual(given, expected)) {
    return new NextResponse("Sign in required", {
      status: 401,
      headers: { "WWW-Authenticate": 'Basic realm="Support chat"' },
    });
  }
  if (request.nextUrl.pathname === "/") {
    return NextResponse.redirect(new URL(`/chat/${crypto.randomUUID()}`, request.url));
  }
}
```

* The browser shows its own sign-in dialog and sends the user name and password with every request after that, including the requests `useChat` makes. Sevalla redirects plain HTTP requests to HTTPS, so the password is encrypted on the way.
* The proxy reads `CHAT_USERNAME` and `CHAT_PASSWORD` when a request arrives, so you set them as runtime environment variables. Without `CHAT_PASSWORD`, it turns every request away.
* A visit to `/` starts a new conversation at `/chat/<id>`. Bookmark that address to come back to the conversation.

Everyone who knows the password shares the chatbot and can open any conversation whose address they know. To give each person their own account, use an authentication library such as [Auth.js](https://authjs.dev) and save the user's ID with each chat.

## Prepare the project for Sevalla

Build the standalone server, and include the migration script in it. In `next.config.ts`, replace the `/* config options here */` line with two settings:

```ts next.config.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
  outputFileTracingIncludes: { "/api/chat": ["./scripts/migrate.mjs"] },
  cacheComponents: true,
  partialPrefetching: true,
  turbopack: {
    rules: {
      "*.css": {
        loaders: ["@tailwindcss/turbopack"],
        as: "*.css",
      },
    },
  },
};

export default nextConfig;
```

Pin the Node.js version:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm pkg set engines.node="24.x"
```

| File or setting | Why Sevalla needs it |
| - | - |
| `output: "standalone"` | `next build` writes a server with only the dependencies it uses. Ingot detects it, copies only that server into the image and starts it with `node server.js`. See [Build a standalone server](https://docs.sevalla.com/guides/javascript/nextjs.md#build-a-standalone-server). |
| `outputFileTracingIncludes` | Copies `scripts/migrate.mjs` into the standalone server, so the migration job finds it in the image. The script's `pg` package is already there, because the route handler uses it. |
| `engines.node` | Ingot builds on the Node.js 24 line. See [Pin the Node.js version](https://docs.sevalla.com/guides/javascript/overview.md#pin-the-node-js-version). |

The standalone server listens on the port in `PORT`, which Sevalla sets to the web process's port (`8080` by default), and Ingot sets `HOSTNAME` to `0.0.0.0` so that it listens on all network interfaces.

To try the chatbot on your computer, put `ANTHROPIC_API_KEY`, `CHAT_USERNAME`, `CHAT_PASSWORD` and the `DB_URL` of a local PostgreSQL database in `.env.local`, which `.gitignore` keeps out of the repository. Then create the table and start the development server:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
node --env-file=.env.local scripts/migrate.mjs
npm run dev
```

Open `http://localhost:3000` and sign in with the user name and password from `.env.local`. Then commit everything and push it to your Git provider:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
git add .
git commit -m "Prepare for Sevalla"
git branch -M main
git remote add origin https://github.com/acme/support-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). To run them with the API, see [Deploy with the API](#deploy-with-the-api).

**1. Create the database**

In the sidebar, click **Databases**, then click **Create database**. Under **Database**, choose **PostgreSQL** and the newest version. Enter `chat-db` as the **Display name**, choose a **Data center** and a **Size**, then click **Create database**. See [Create a database](https://docs.sevalla.com/databases/create.md) 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 `chat-db` as the **Service**, turn on **Add environment variables to the application**, and click **Add internal connection**. Sevalla adds `DB_URL` and the other connection variables to the application. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

**4. Add the API key and the password**

Click the **Environment variables** tab, then click **Add environment variables**. Add these variables, clear **Available during buildtime** so that they're available during runtime only, and click **Add environment variables**:

| Variable | Value |
| - | - |
| `ANTHROPIC_API_KEY` | Your Anthropic API key |
| `CHAT_USERNAME` | The user name people sign in with, for example `acme` |
| `CHAT_PASSWORD` | A long random password. Generate one with `node -e "console.log(crypto.randomUUID())"`. |

The build doesn't need them, and a runtime-only variable never reaches the image.

**5. Create the migration job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Migrate` as the **Name** and `node scripts/migrate.mjs` as the **Custom start command**. Keep the **Start policy** at **Before deployment** and the **Resource** at **H1**, then click **Create process**.

**6. Deploy**

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

**7. 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).

![The Create process sheet with Job selected, the Name and Custom start command fields, the Start policy set to Before deployment, and the H1 resource](https://assets.sevalla.com/screenshots/light/app-processes-job-create.png)

## Verify the deployment

1. On the **Deployments** tab, wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**. Open the deployment to read its build log. Ingot's [build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan) comes first:

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

   During the rollout, the log shows `Migrate has been successful`: the job created the `chats` table.
2. Click the domain under the application's name, for example `support-chat-x7k2m.sevalla.app`. The browser asks for a user name and password. Enter `CHAT_USERNAME` and `CHAT_PASSWORD`, and the address changes to `/chat/` followed by the conversation's ID.
3. Ask `What time is it in Tokyo?`. A `Called getTime` line appears, then the answer streams in word by word with the time in Tokyo.
4. Reload the page. The conversation is still there: the page loaded it from the database.

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

To check the same from a terminal, set `CHAT_PASSWORD` in your shell to the password. Without it, the application answers `401`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -o /dev/null -w "%{http_code}\n" https://support-chat-x7k2m.sevalla.app/
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
401
```

With it, the chatbot calls the tool. This prints the tool's input:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -sN -u "acme:$CHAT_PASSWORD" https://support-chat-x7k2m.sevalla.app/api/chat \
  -H "Content-Type: application/json" \
  -d '{"id":"check-1","message":{"id":"m1","role":"user","parts":[{"type":"text","text":"What time is it in Tokyo?"}]}}' \
  | jq -Rrc 'select(startswith("data: {")) | .[6:] | fromjson | select(.type == "tool-input-available") | .input'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"timeZone":"Asia/Tokyo"}
```

With the API, [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns the deployment's `status`, `success` when it's deployed, and [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) with `search=Migrate has been successful` returns the job's line; see [Deploy with the API](#deploy-with-the-api).

If the browser keeps asking for the password, or an answer shows `An error occurred.`, see [Troubleshoot](#troubleshoot).

## Check the stream with curl

Sevalla and Cloudflare pass server-sent events through as they're written, without buffering the response. To watch the events arrive, send a message with `curl -N`, which prints each event as it comes, with `CHAT_PASSWORD` set in your shell to the password:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -N -u "acme:$CHAT_PASSWORD" https://support-chat-x7k2m.sevalla.app/api/chat \
  -H "Content-Type: application/json" \
  -d '{"id":"curl-test","message":{"id":"m1","role":"user","parts":[{"type":"text","text":"What time is it in Tokyo?"}]}}'
```

The response is a stream of `data:` lines: the tool call and its result, then the answer in small pieces.

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
data: {"type":"tool-input-available","toolCallId":"toolu_01JymS9ThAwDyKYhqhqAcqr5","toolName":"getTime","input":{"timeZone":"Asia/Tokyo"},"providerMetadata":{"anthropic":{"caller":{"type":"direct"}}}}

data: {"type":"text-delta","id":"0","delta":"It's curr"}

data: {"type":"text-delta","id":"0","delta":"ently 1:48 PM"}
```

The first event arrives in under a second. The first words follow once the model starts writing, and the rest arrive as the model writes them: in a test on Sevalla, the text of a 500-word answer arrived in about 300 pieces over 6 seconds. The conversation is saved as `curl-test`, so the next request with that ID continues it.

## Long responses and several instances

**Time limits.** Cloudflare ends a request with error `524` when the application sends nothing for too long. A streamed answer sends data all the time, so it stays open for as long as the model writes. A tool that runs for minutes sends nothing while it runs: start that work in a [background worker](https://docs.sevalla.com/applications/processes.md#background-workers) and have the tool return right away. See [504 Gateway Timeout and Cloudflare error 524](https://docs.sevalla.com/applications/troubleshooting/http-errors.md#504-gateway-timeout-and-cloudflare-error-524).

**Deployments.** Sevalla sends `SIGTERM` to each instance it replaces. The Next.js server stops taking new requests, finishes the ones in progress, including a streaming answer, then exits. See [Processes](https://docs.sevalla.com/applications/processes.md#how-processes-run).

**Several instances.** Sevalla spreads requests across the web process's instances, and two messages of one conversation can reach different instances. The chatbot keeps nothing in memory between requests: each request loads the conversation from PostgreSQL. You can [scale](https://docs.sevalla.com/applications/scaling.md) the web process to more instances without other changes.

## Deploy with a Dockerfile

Ingot needs no Dockerfile. Write one when you want full control of the image: the base image, the system packages and every layer. It runs the same standalone server. 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 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-trixie-slim
WORKDIR /app
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=8080
COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
RUN mkdir .next/cache .next/server/route-cache \
    && chown 1000:1000 .next/cache .next/server/route-cache
USER 1000:1000
EXPOSE 8080
CMD ["node", "server.js"]
```

| Line or choice | Why |
| - | - |
| `node:24-trixie`, then `node:24-trixie-slim` | Node.js 24, the line that `engines.node` pins, on Debian 13. The slim final image leaves out the build tools. A line tag picks up Node.js and Debian security fixes at every build, which a digest would stop. |
| Two stages | The build stage installs every dependency and runs `next build`. The final image gets only the standalone server, with the dependencies it uses and `scripts/migrate.mjs`. |
| `public` and `.next/static` | The standalone server doesn't include them, so the Dockerfile copies them next to it. |
| The lock file before the source | `npm ci` installs exactly what `package-lock.json` lists, and the [build cache](https://docs.sevalla.com/applications/builds/overview.md#build-cache) reuses that layer until the lock file changes, so a code change doesn't reinstall the dependencies. |
| `USER 1000:1000` | The server runs as the image's `node` user, not as root. Root owns the copied files, so the server can't change its own code. It owns only the two folders Next.js writes to. |
| `CMD` in exec form | Node.js is the container's main process and gets `SIGTERM` when an instance stops, so Next.js finishes the requests in progress before it exits. |
| `HOSTNAME=0.0.0.0`, `PORT=8080`, `EXPOSE 8080` | The standalone server listens on the address in `HOSTNAME` and the port in `PORT`. Sevalla sets `PORT` to the web process's port; `8080` is the default when you run the image yourself. |

The API key and the password are runtime-only environment variables, so they never enter the image.

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.next
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 shows an **Ignored** row with `7 patterns from .dockerignore`. 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 build log shows `📦 Building and pushing docker image using Dockerfile...` where Ingot's build plan was, then `Migrate has been successful`: the job runs `node scripts/migrate.mjs` in the new image. The chatbot asks for the password and streams answers as before, and the deployment's **Dockerfile** tab shows your Dockerfile. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md).

## Change the model or provider

The model is set in `app/api/chat/route.ts`. For harder questions, change `claude-haiku-5-5` to `claude-sonnet-5-5`, which costs more per token. To use OpenAI instead, install its provider:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm install @ai-sdk/openai
```

In `app/api/chat/route.ts`, import `openai` from `@ai-sdk/openai` in place of `anthropic`, set `model: openai("gpt-5.4-mini")`, and add an `OPENAI_API_KEY` environment variable, available during runtime only. The tool, the history and the stream work the same.

To reach models from many providers with one key, use OpenRouter's AI SDK provider: see [Use the AI SDK](https://docs.sevalla.com/guides/ai/openrouter.md#use-the-ai-sdk).

## 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, runs the `Migrate` job, then replaces the web process's instances. When you change the table, add the change to `scripts/migrate.mjs` in a form that's safe to run again, such as `ALTER TABLE chats ADD COLUMN IF NOT EXISTS title text`. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Add a custom domain

To serve the chatbot 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 |
| - | - | - |
| The browser keeps asking for the user name and password, and `curl` gets `401` with `Sign in required` | `CHAT_USERNAME` or `CHAT_PASSWORD` isn't set, isn't **Available during runtime**, or doesn't match what you type | Check both variables on the **Environment variables** tab, then deploy. |
| An answer shows `An error occurred.`, and the runtime logs show `Anthropic API key is missing` | `ANTHROPIC_API_KEY` isn't set or isn't **Available during runtime** | Add the variable, then deploy. |
| The deployment fails at `Migrate has failed` | The job can't reach the database, or `DB_URL` isn't set | On the **Logs** tab, choose the `Migrate` process to read its output. Check that the internal connection exists and that `DB_URL` is set. |
| A request fails with Cloudflare error `524` | The response sent nothing for too long, for example while a slow tool ran | Keep tools fast, or move the work to a [background worker](https://docs.sevalla.com/applications/processes.md#background-workers). |

For build failures, see [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds). To keep a chatbot's answers grounded in your own documents, see [RAG with pgvector](https://docs.sevalla.com/guides/ai/rag-pgvector.md).

## Deploy with the API

These calls create the same database, application, variables and job as [Deploy to Sevalla](#deploy-to-sevalla), and deploy them. Run them in order in one shell: each step saves the IDs that the next steps use. Before you start:

* Create an [API key](https://docs.sevalla.com/developer-tools/api-keys.md) that can create applications and databases, and set it as `SEVALLA_API_KEY`. Set `ANTHROPIC_API_KEY` to your Anthropic API key and `CHAT_PASSWORD` to the password people will sign in with.
* Push the project to GitHub, GitLab or Bitbucket, and [connect that Git account](https://docs.sevalla.com/git/overview.md) to Sevalla. If Sevalla can't read the repository, the **Create the application** step fails with `400` and `Cannot access repository <url>: Repository not found or you don't have access.`, followed by where to grant access.
* Install `curl`, `jq` and `openssl`.

Each step lists the same action in the [Sevalla CLI](https://docs.sevalla.com/developer-tools/cli.md) and in the [Sevalla MCP server](https://docs.sevalla.com/ai-agents/overview.md).

**1. Find the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns every location you can use. Pick one by its region ID from [Locations](https://docs.sevalla.com/platform/locations.md), here Frankfurt, Germany:

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

CLI: `sevalla resources clusters --json`. 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`):

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

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

**3. Find the smallest database size**

[List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB1_ID=$(curl -s "$API/resources/database-resource-types" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.name == "db1") | .id')
```

CLI: `sevalla resources db-types --json`. MCP: `get_platform_options`.

**4. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`). The API doesn't generate a password, so generate one:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_PASSWORD=$(openssl rand -hex 16)
DB_ID=$(jq -n --arg cluster "$CLUSTER_ID" --arg size "$DB1_ID" --arg password "$DB_PASSWORD" \
  '{display_name: "chat-db", type: "postgresql", version: "18", cluster_id: $cluster, resource_type_id: $size,
    db_name: "support_chat", db_user: "support_chat", db_password: $password}' \
  | curl -s -X POST "$API/databases" -H "Authorization: Bearer $SEVALLA_API_KEY" \
      -H "Content-Type: application/json" -d @- | jq -r .id)
```

The database starts with the status `creating`. CLI: `sevalla databases create --name chat-db --type postgresql --db-version 18 --cluster $CLUSTER_ID --resource-type $DB1_ID --db-name support_chat --db-user support_chat --db-password $DB_PASSWORD`. MCP: `create_database`, which generates the password and doesn't return it.

**5. 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=$(jq -n --arg cluster "$CLUSTER_ID" \
  '{display_name: "support-chat", cluster_id: $cluster, source: "privateGit", git_type: "github",
    repo_url: "https://github.com/acme/support-chat", default_branch: "main"}' \
  | curl -s -X POST "$API/applications" -H "Authorization: Bearer $SEVALLA_API_KEY" \
      -H "Content-Type: application/json" -d @- | jq -r .id)
```

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

**6. Find the web process**

[List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID=$(curl -s "$API/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 --json`. MCP: `get_application`.

**7. Set the web process to H1**

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -n --arg size "$H1_ID" '{resource_type_id: $size}' \
  | curl -s -X PATCH "$API/applications/$APP_ID/processes/$WEB_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" \
      -H "Content-Type: application/json" -d @- | jq -r .resource_type_name
```

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

**8. Connect the database**

[Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`). Unlike the dashboard, it adds no environment variables:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -n --arg db "$DB_ID" '{target_id: $db, target_type: "database"}' \
  | curl -s -X POST "$API/applications/$APP_ID/internal-connections" -H "Authorization: Bearer $SEVALLA_API_KEY" \
      -H "Content-Type: application/json" -d @- | jq -r .id
```

CLI: `sevalla apps internal-connections create $APP_ID --target-id $DB_ID --target-type database`. MCP: `sevalla_api_create` with `createApplicationInternalConnectionV3` and the same body.

**9. Build the database URL**

[Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) returns the internal hostname and port. Build `DB_URL` in the format the dashboard uses:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB=$(curl -s "$API/databases/$DB_ID" -H "Authorization: Bearer $SEVALLA_API_KEY")
DB_URL="postgres://support_chat:$DB_PASSWORD@$(echo "$DB" | jq -r .internal_hostname):$(echo "$DB" | jq -r .internal_port)/support_chat"
```

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

**10. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once for each variable, available during runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for VAR in "DB_URL=$DB_URL" "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" "CHAT_USERNAME=acme" "CHAT_PASSWORD=$CHAT_PASSWORD"; do
  jq -n --arg key "${VAR%%=*}" --arg value "${VAR#*=}" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}' \
    | curl -s -X POST "$API/applications/$APP_ID/env-vars" -H "Authorization: Bearer $SEVALLA_API_KEY" \
        -H "Content-Type: application/json" -d @- | jq -r .key
done
```

It prints the four keys. CLI: `sevalla apps env-vars create $APP_ID --key CHAT_USERNAME --value acme` makes a variable available during build and runtime, with no option for runtime only. MCP: `set_environment_variables` with `runtime: true` and `buildtime: false`.

**11. Create the migration job**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) needs a size and a scaling strategy for every process, and a start policy for a job:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -n --arg size "$H1_ID" \
  '{display_name: "Migrate", type: "job", entrypoint: "node scripts/migrate.mjs", resource_type_id: $size,
    job_start_policy: "beforeDeployment", scaling_strategy: {type: "manual", config: {instanceCount: 1}}}' \
  | curl -s -X POST "$API/applications/$APP_ID/processes" -H "Authorization: Bearer $SEVALLA_API_KEY" \
      -H "Content-Type: application/json" -d @- | jq -r .display_name
```

CLI: `sevalla apps processes create $APP_ID --name Migrate --type job --resource-type $H1_ID --command "node scripts/migrate.mjs" --job-start-policy beforeDeployment`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**12. Wait for the database**

[Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) 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 "$API/databases/$DB_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .status)" = ready ] && break;
  sleep 10
done
```

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

**13. Deploy**

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

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

**14. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until its status is `success` or `failed`:

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

It checks every 15 seconds for up to 30 minutes, keeps going when a request fails, and prints `success` when the deployment is done. While the deployment runs, its status is `waiting`, then `inProgress`. CLI: `sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment`.

**15. Check the migration job**

[Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) with a search returns the job's line from the build log:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -G "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?limit=5000" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  --data-urlencode "search=Migrate has been successful" | jq -r '.[].message'
```

It prints `✅ Migrate has been successful`, between color codes. If the deployment failed, run it without `--data-urlencode` to read the whole log. CLI: `sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID --search "Migrate has been successful"`. MCP: `get_deployment_logs` with `search`.

**16. Get the domain**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`):

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

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

**17. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`):

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

It prints `true`. CLI: `sevalla apps update $APP_ID --data '{"auto_deploy": true}'`. MCP: `sevalla_api_update` with `updateApplicationV3` and the same body.

Then run the checks from [Verify the deployment](#verify-the-deployment) against `https://$HOST`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -o /dev/null -w "%{http_code}\n" "https://$HOST/"
curl -sN -u "acme:$CHAT_PASSWORD" "https://$HOST/api/chat" \
  -H "Content-Type: application/json" \
  -d '{"id":"check-1","message":{"id":"m1","role":"user","parts":[{"type":"text","text":"What time is it in Tokyo?"}]}}' \
  | jq -Rrc 'select(startswith("data: {")) | .[6:] | fromjson | select(.type == "tool-input-available") | .input'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
401
{"timeZone":"Asia/Tokyo"}
```


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