> ## 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 Mastra agent

> Deploy a Mastra agent with a tool, a workflow and memory in PostgreSQL to Sevalla, protect its API with a token, and call it over HTTP with streaming.

A Mastra agent runs on Sevalla as an application: `mastra build` bundles your agents, tools and workflows into a Hono server in `.mastra/output`, and the web process runs it with `node .mastra/output/index.mjs`. The agent keeps its conversation memory and workflow runs in a Sevalla PostgreSQL database, and a token protects its HTTP API. This guide builds a weather agent that answers with a forecast tool, plans activities with a two-step workflow and remembers each conversation.

## 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 .mastra/output/index.mjs`, from `scripts.start` in `package.json` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process |
| Services | PostgreSQL 18 over an internal connection |
| Disk | None |
| Health check | `GET /health` returns `200` with `{"success":true}` (readiness probe) |
| Size | **H1** for the web process |

### 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 |
| `MASTRA_API_TOKEN` | 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: every agent call spends from it.

## Create the project

Create an empty Mastra project, then add Mastra's memory, its PostgreSQL storage and Zod:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npx create-mastra@latest weather-agent --empty --no-skills --no-install
cd weather-agent
npm install @mastra/memory @mastra/pg zod
```

`--empty` creates the project without example code, `--no-skills` leaves out the Mastra skills it otherwise adds for AI coding assistants, and `--no-install` leaves the installation to the `npm install` that follows. The project has Mastra 1 (`@mastra/core` 1.80 and the `mastra` command 1.36), TypeScript 6 and a Git repository.

> [!WARNING]
>
> Without `--empty`, `create-mastra` starts from a template whose agent has a workspace with file and shell tools. Don't deploy an agent with shell or file-writing tools to a public address: anyone who can talk to it can make it run commands.

The project's `tsconfig.json` doesn't load the Node.js types, so your editor doesn't know `process`. Add `"types": ["node"]` to it:

```json tsconfig.json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"],
    "noEmit": true,
    "outDir": "dist"
  },
  "include": [
    "src/**/*"
  ]
}
```

### Add a tool

The tool gets a 3-day forecast from [Open-Meteo](https://open-meteo.com), which needs no API key:

```ts src/mastra/tools/weather-tool.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';

export async function getWeather(city: string) {
  const geo = await fetch(
    `https://geocoding-api.open-meteo.com/v1/search?count=1&name=${encodeURIComponent(city)}`,
  ).then((res) => res.json());
  const place = geo.results?.[0];
  if (!place) throw new Error(`No location found for ${city}`);

  const forecast = await fetch(
    `https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}&longitude=${place.longitude}` +
      '&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max&forecast_days=3&timezone=auto',
  ).then((res) => res.json());

  return {
    location: `${place.name}, ${place.country}`,
    days: forecast.daily.time.map((date: string, i: number) => ({
      date,
      maxC: forecast.daily.temperature_2m_max[i],
      minC: forecast.daily.temperature_2m_min[i],
      rainChance: forecast.daily.precipitation_probability_max[i],
    })),
  };
}

export const weatherTool = createTool({
  id: 'get-weather',
  description: 'Get the 3-day weather forecast for a city',
  inputSchema: z.object({ city: z.string() }),
  execute: async ({ city }) => getWeather(city),
});
```

The model chooses the city, so the tool treats it as untrusted input: `encodeURIComponent` keeps it inside the query string, and the tool can only read a forecast.

### Add the agent

```ts src/mastra/agents/weather-agent.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { Agent } from '@mastra/core/agent';
import { Memory } from '@mastra/memory';
import { weatherTool } from '../tools/weather-tool';

export const weatherAgent = new Agent({
  id: 'weather-agent',
  name: 'Weather Agent',
  instructions:
    'You help people plan around the weather. Use the get-weather tool for forecasts. Answer in 3 sentences or fewer.',
  model: 'anthropic/claude-haiku-5-5',
  tools: { weatherTool },
  memory: new Memory({ options: { lastMessages: 20 } }),
});
```

* `model` uses Claude Haiku 5.5, the least expensive current Claude model. Mastra reads the key from `ANTHROPIC_API_KEY`. For harder tasks, use `anthropic/claude-sonnet-5-5`. To use OpenAI, set `openai/gpt-5.4-mini` and an `OPENAI_API_KEY` variable instead.
* `memory` keeps the last 20 messages of each conversation in the storage that `src/mastra/index.ts` sets up, PostgreSQL here.

### Add a workflow

The workflow runs two steps in a fixed order: it fetches the forecast with the same function as the tool, then asks the agent to suggest an activity for each day.

```ts src/mastra/workflows/weather-workflow.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { createStep, createWorkflow } from '@mastra/core/workflows';
import { z } from 'zod';
import { getWeather } from '../tools/weather-tool';

const forecastSchema = z.object({
  location: z.string(),
  days: z.array(z.object({ date: z.string(), maxC: z.number(), minC: z.number(), rainChance: z.number() })),
});

const fetchForecast = createStep({
  id: 'fetch-forecast',
  inputSchema: z.object({ city: z.string() }),
  outputSchema: forecastSchema,
  execute: async ({ inputData }) => getWeather(inputData.city),
});

const planActivities = createStep({
  id: 'plan-activities',
  inputSchema: forecastSchema,
  outputSchema: z.object({ plan: z.string() }),
  execute: async ({ inputData, mastra }) => {
    const agent = mastra.getAgent('weatherAgent');
    const result = await agent.generate(
      `Suggest one activity for each day of this forecast, one line per day: ${JSON.stringify(inputData)}`,
    );
    return { plan: result.text };
  },
});

export const weatherWorkflow = createWorkflow({
  id: 'weather-workflow',
  inputSchema: z.object({ city: z.string() }),
  outputSchema: z.object({ plan: z.string() }),
})
  .then(fetchForecast)
  .then(planActivities)
  .commit();
```

### Configure storage and the server

Replace `src/mastra/index.ts`:

```ts src/mastra/index.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { Mastra } from '@mastra/core/mastra';
import { SimpleAuth } from '@mastra/core/server';
import { PostgresStore } from '@mastra/pg';
import { weatherAgent } from './agents/weather-agent';
import { weatherWorkflow } from './workflows/weather-workflow';

export const mastra = new Mastra({
  agents: { weatherAgent },
  workflows: { weatherWorkflow },
  storage: new PostgresStore({ id: 'storage', connectionString: process.env.DB_URL }),
  server: {
    host: '0.0.0.0',
    drainTimeout: 10_000,
    auth: new SimpleAuth({
      tokens: process.env.MASTRA_API_TOKEN ? { [process.env.MASTRA_API_TOKEN]: { id: 'api' } } : {},
    }),
  },
});
```

| Setting | Why |
| - | - |
| `PostgresStore` | Keeps memory, workflow runs and traces in the database in `DB_URL`, the variable Sevalla adds when you connect it. Mastra creates and updates its tables the first time it uses the database, so there's no migration to run. |
| `host: '0.0.0.0'` | Mastra's documented default is `localhost`, which Sevalla's proxy can't reach. The port comes from `PORT`, which Sevalla sets to the web process's port (`8080` by default). |
| `drainTimeout: 10_000` | When Sevalla replaces an instance, the server stops taking new requests and gives the ones in progress, including streams and workflow runs, up to 10 seconds to finish. Mastra can take twice that plus 5 seconds to shut down, which fits in the 25 seconds Sevalla allows after `SIGTERM`. |
| `SimpleAuth` | Every API request must send `Authorization: Bearer` with the token in `MASTRA_API_TOKEN`. Without the variable, the server turns every API request away. |

## Prepare the project for Sevalla

Start the built server with Node.js and pin the Node.js version:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm pkg set scripts.start="node .mastra/output/index.mjs" engines.node="24.x"
```

| Setting | Why Sevalla needs it |
| - | - |
| `scripts.build` | `mastra build`, from the template. [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) runs it: it bundles the project into `.mastra/output`, with a `node_modules` of its own. |
| `scripts.start` | Ingot starts the web process with the `start` script. The template's `mastra start` runs the `mastra` command, a devDependency, so Ingot would keep every devDependency in the image. `node .mastra/output/index.mjs` runs the built server directly, so Ingot removes them after the build. |
| `engines.node` | Ingot builds on the Node.js 24 line. The template's `>=22.13.0` lets a new Ingot release move it. See [Pin the Node.js version](https://docs.sevalla.com/guides/javascript/overview.md#pin-the-node-js-version). |

The template's `.gitignore` already keeps `node_modules`, `.mastra` and `.env` out of the repository.

To try the agent on your computer, put `ANTHROPIC_API_KEY`, `MASTRA_API_TOKEN` and the `DB_URL` of a local PostgreSQL database in `.env`, and run `npm run dev`. Studio, Mastra's UI for chatting with agents and running workflows, opens at `http://localhost:4111`. It asks you to sign in: open `http://localhost:4111/?auth_header=Bearer%20<token>` with your `MASTRA_API_TOKEN` instead.

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/weather-agent.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 `weather-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 `weather-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 token**

Click the **Environment variables** tab, then click **Add environment variables**. Add `ANTHROPIC_API_KEY` with your Anthropic API key and `MASTRA_API_TOKEN` with a long random value, for example from `node -e "console.log(crypto.randomUUID())"`. Clear **Available during buildtime** so that they're available during runtime only, and click **Add environment variables**. Keep a copy of the token: you send it with every API request.

**5. Deploy**

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

**6. 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 Add environment variables sheet with the Availability checkboxes and rows of keys and values](https://assets.sevalla.com/screenshots/light/app-environment-variables-add.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"}}
   ╭─ ■ Node.js 24 application ──────────────────────────────────────────────────╮
   │ Node.js          24                             package.json engines "24.x" │
   │ Package manager  npm                            package-lock.json           │
   │ Start            node .mastra/output/index.mjs  package.json scripts.start  │
   │ DevDependencies  removed after the build                                    │
   ├─ Image ─────────────────────────────────────────────────────────────────────┤
   │ Build            node:24-trixie                                             │
   │ Runtime          node:24-trixie-slim                                        │
   │ User             1000:1000                                                  │
   │ Init             tini                                                       │
   │ Port             $PORT, default 8080                                        │
   ╰────────────────────────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

   Later, the `mastra build` step ends with `Build successful, you can now deploy the .mastra/output directory to your target platform.`
2. Click the domain under the application's name, for example `weather-agent-x7k2m.sevalla.app`. The **Mastra Server** page opens with the server's endpoints.
3. Check the health endpoint and the token from a terminal, with `MASTRA_API_TOKEN` set in your shell to the token:

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

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"success":true}
   ```

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

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"error":"Invalid or expired token"}
   ```

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://weather-agent-x7k2m.sevalla.app/api/agents -H "Authorization: Bearer $MASTRA_API_TOKEN" | jq -r 'keys[]'
   ```

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

![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` when it's deployed, and [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) returns its build log; see [Deploy with the API](#deploy-with-the-api).

## Call the agent over HTTP

The Mastra server exposes each agent at `/api/agents/<agent-id>`. `stream` sends the answer as server-sent events while the agent works; `generate` waits and returns it in one response. Send a `memory` object with a `thread`, one conversation, and a `resource`, the user or customer it belongs to, so the agent remembers earlier messages:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -N https://weather-agent-x7k2m.sevalla.app/api/agents/weather-agent/stream \
  -H "Authorization: Bearer $MASTRA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Will I need an umbrella in Lisbon tomorrow?"}],"memory":{"thread":"lisbon-trip","resource":"acme"}}'
```

The response is a stream of `data:` events: a `tool-call` for `weatherTool` with `{"city":"Lisbon"}`, its `tool-result`, then `text-delta` events with the answer in small pieces. To print only the answer as it arrives, pipe the stream through `jq`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -sN https://weather-agent-x7k2m.sevalla.app/api/agents/weather-agent/stream \
  -H "Authorization: Bearer $MASTRA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"And the day after?"}],"memory":{"thread":"lisbon-trip","resource":"acme"}}' \
  | jq -Rrj --unbuffered 'select(startswith("data: {")) | .[6:] | fromjson | select(.type == "text-delta") | .payload.text'
```

The agent knows that the question is about Lisbon, because it loaded the thread from PostgreSQL. The answer depends on the forecast, for example:

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
No umbrella needed the day after either. The forecast shows a 0% chance of rain, with highs around 24°C (76°F) and lows around 13°C (56°F).
```

### Run the workflow

`start-async` runs the workflow and returns when it's done:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://weather-agent-x7k2m.sevalla.app/api/workflows/weather-workflow/start-async \
  -H "Authorization: Bearer $MASTRA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"inputData":{"city":"Berlin"}}' | jq -r .status
```

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

The response's `result.plan` holds one activity per day, and `steps` the output of `fetch-forecast` and `plan-activities`. A run that takes longer than a request should wait starts in the background instead: create a run, start it, and ask for its status until it's `success`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
RUN_ID=$(curl -s -X POST https://weather-agent-x7k2m.sevalla.app/api/workflows/weather-workflow/create-run \
  -H "Authorization: Bearer $MASTRA_API_TOKEN" -H "Content-Type: application/json" -d '{}' | jq -r .runId)

curl -s -X POST "https://weather-agent-x7k2m.sevalla.app/api/workflows/weather-workflow/start?runId=$RUN_ID" \
  -H "Authorization: Bearer $MASTRA_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"inputData":{"city":"Oslo"}}'

curl -s "https://weather-agent-x7k2m.sevalla.app/api/workflows/weather-workflow/runs/$RUN_ID" \
  -H "Authorization: Bearer $MASTRA_API_TOKEN" | jq -r .status
```

`start` returns `{"message":"Workflow run started"}` right away, and the run's status changes from `running` to `success` a few seconds later. Mastra keeps the run in PostgreSQL, so any instance can answer the status request.

## Use Studio safely

Studio has full access to the agents, workflows and memory of the server it connects to. The production build doesn't include it, so the deployed application serves only:

| Path | Who can open it |
| - | - |
| `/` | Everyone: a static page that lists the endpoints |
| `/health` | Everyone: `{"success":true}` |
| `/api/...` | Requests with the token |
| `/api/openapi.json`, `/swagger-ui` | Nobody: Mastra turns them off in production, and they return `404` |

To look at the deployed agent's threads, traces and workflow runs in Studio, run Studio on your computer and connect it to the application:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npx mastra studio --server-host weather-agent-x7k2m.sevalla.app --server-port 443 --server-protocol https
```

Open `http://localhost:3000/?auth_header=Bearer%20<token>` with your `MASTRA_API_TOKEN`. Studio keeps the token in memory for the session and removes it from the address bar. Don't build with `mastra build --studio` for a public application: it serves Studio to anyone who finds the address.

The token gives full access to every agent, workflow and conversation. Keep it in the services that call the agent, never in a browser. For one account per user, replace `SimpleAuth` with one of [Mastra's authentication providers](https://mastra.ai/docs/auth/overview), such as JSON Web Tokens. Content the agent reads, such as a web page or a ticket, can carry instructions meant for the model: give the agent tools that read, and let your own code, not the model, take any step you can't undo.

## Add a health check

A readiness probe makes each new instance wait for `/health` to answer before it gets traffic, so a deployment switches over only to instances that serve requests. `/health` needs no token.

**1. Open the web process**

On the application's **Processes** tab, click **⋯** on the **Web** process, then click **Update process**.

**2. Turn on the readiness probe**

Under **Health checks**, turn on **Readiness probe** and set the **Path** to `/health`. Keep the **Port** at the web process's port, `8080` unless you changed it.

**3. Save and deploy**

Click **Update process**, then click **Deploy** in the undeployed changes bar at the bottom of the page.

The deployment succeeds once the new instance answers `/health`. See [Health checks](https://docs.sevalla.com/applications/processes.md#health-checks).

## How the agent runs on Sevalla

**Streaming.** Sevalla and Cloudflare pass the server-sent events through as Mastra writes them, without buffering. In a test, the first event arrived after 0.3 seconds, the tool call after 1.1 seconds and the first words of the answer after 3.3 seconds.

**Time limits.** Cloudflare ends a request with error `524` when the application sends nothing for too long. A stream keeps sending events while the agent works, but `generate` and `start-async` send nothing until they're done. For runs that can take minutes, use `stream`, or start the workflow with `create-run` and `start` and ask for its status. 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 server logs `Shutting down Mastra server`, finishes the requests in progress for up to `drainTimeout`, then exits. A stream that runs longer is cut off: the client sees the response end early and can ask again, because the conversation is in PostgreSQL.

**Several instances.** Sevalla spreads requests across the web process's instances. Memory and workflow runs live in PostgreSQL, so any instance can continue a conversation or report a run. You can [scale](https://docs.sevalla.com/applications/scaling.md) the web process to more instances without other changes.

**Size.** **H1** runs this agent: in tests it used up to 256 MB of its 300 MB of memory. Choose **S1** or larger for more traffic or more agents.

## 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. 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 PORT=8080
COPY --from=build /app/.mastra/output ./
USER 1000:1000
EXPOSE 8080
CMD ["node", "index.mjs"]
```

| 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 `mastra build`. The final image gets only `.mastra/output`: the bundled server and the production dependencies `mastra build` installed next to it. The `mastra` command and TypeScript stay in the build stage. |
| 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 writes nothing to disk: its data is in PostgreSQL. |
| `CMD` in exec form, no init process | Node.js is the container's main process. Mastra handles `SIGTERM` itself: it drains the requests in progress, then exits. |
| `PORT=8080` and `EXPOSE 8080` | Mastra listens on the port in `PORT`, which Sevalla sets to the web process's port. `8080` is the default when you run the image yourself. |

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

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.mastra
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. When the deployment is **Deployed**, `/health` returns `{"success":true}` and the agent answers as before, with the conversations it had before: they're in PostgreSQL. The deployment's **Dockerfile** tab shows your Dockerfile. 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 builds the image, then replaces the web process's instances. When a new Mastra version changes its tables, Mastra updates them the first time it uses the database. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Add a custom domain

To serve the agent's API from your own domain, such as `agent.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 instance doesn't start, and the runtime logs show `PostgresStore: invalid config. Provide either {pool}, {connectionString}, {host,port,database,user,password}, or a pg ClientConfig` | `DB_URL` isn't set or isn't **Available during runtime** | Connect the database with **Add environment variables to the application** turned on, then deploy. |
| Every API request returns `401` with `{"error":"Invalid or expired token"}` | The request has no token or the wrong one, or `MASTRA_API_TOKEN` isn't set on the application | Send `Authorization: Bearer` with the value of `MASTRA_API_TOKEN`. Check the variable on the **Environment variables** tab, then deploy. |
| Studio shows `Sign in to continue` and an email and password form | The server uses a token, and Studio's form is for other providers | Open Studio with `?auth_header=Bearer%20<token>`, as in [Use Studio safely](#use-studio-safely). |
| A request fails with Cloudflare error `524` | `generate` or `start-async` sent nothing for too long | Use `stream`, or start the workflow with `create-run` and `start`. |

For build failures, see [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds).

## Deploy with the API

These calls create the same database, application, variables and health check as [Deploy to Sevalla](#deploy-to-sevalla) and [Add a health check](#add-a-health-check), 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 `MASTRA_API_TOKEN` to the agent's token, for example from `node -e "console.log(crypto.randomUUID())"`.
* 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: "weather-db", type: "postgresql", version: "18", cluster_id: $cluster, resource_type_id: $size,
    db_name: "weather", db_user: "weather", 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 weather-db --type postgresql --db-version 18 --cluster $CLUSTER_ID --resource-type $DB1_ID --db-name weather --db-user weather --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: "weather-agent", cluster_id: $cluster, source: "privateGit", git_type: "github",
    repo_url: "https://github.com/acme/weather-agent", 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 weather-agent --cluster $CLUSTER_ID --source privateGit --git-type github --repository https://github.com/acme/weather-agent --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 size and the health check**

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`) sets the size and the readiness probe in one call. Send `timeoutSeconds`: without it, the API sets 120 seconds, where the dashboard sets 1:

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

It prints `{"size":"h1","path":"/health"}`. CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID` sets the size; the CLI has no option for health checks. MCP: `sevalla_api_update` with `updateProcessV3` and the same body.

**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://weather:$DB_PASSWORD@$(echo "$DB" | jq -r .internal_hostname):$(echo "$DB" | jq -r .internal_port)/weather"
```

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" "MASTRA_API_TOKEN=$MASTRA_API_TOKEN"; 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 three keys. CLI: `sevalla apps env-vars create $APP_ID --key MASTRA_API_TOKEN --value $MASTRA_API_TOKEN` 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. 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`.

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

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

**14. Check the build**

[Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) with a search returns the end of the `mastra build` step:

```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=Build successful" | jq -r '.[].message'
```

It prints a line that ends with `Build successful, you can now deploy the .mastra/output directory to your target platform.`, 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 "Build successful"`. MCP: `get_deployment_logs` with `search`.

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

**16. 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 -w "\n" "https://$HOST/health"
curl -s -w "\n" "https://$HOST/api/agents"
curl -s "https://$HOST/api/agents" -H "Authorization: Bearer $MASTRA_API_TOKEN" | jq -r 'keys[]'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"success":true}
{"error":"Invalid or expired token"}
weather-agent
```


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