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

# Build RAG with pgvector

> Build a RAG API on Sevalla with FastAPI: OpenAI embeddings in PostgreSQL with pgvector and an HNSW index, and Claude answers that cite their sources, streamed over SSE.

A retrieval-augmented generation (RAG) application answers questions from your own documents: it stores the documents as embeddings in PostgreSQL with the pgvector extension, finds the passages closest to each question, and sends them to a model with the question. This guide builds one with FastAPI and deploys it to Sevalla as one application: a job creates the schema and its vector index before each deployment, a second job embeds the documents in your repository after each deployment, and the web process answers on `/ask`, streaming Claude's answer with numbered citations.

The guide uses Python because the AI providers' SDKs and most RAG tooling start there. The SQL is plain PostgreSQL, so the schema and the search query work the same from any language with a PostgreSQL driver.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Python 3.14, uv), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `uvicorn app.main:app --host 0.0.0.0 --port "$PORT"`, which Ingot detects from `app/main.py` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`python -m app.migrate`, **Before deployment**); `Ingest` job (`python -m app.ingest`, **After successful deployment**) |
| Services | PostgreSQL 18 with **Enable pgvector extension**, over an internal connection; the OpenAI and Anthropic APIs over HTTPS |
| Disk | None |
| Health check | `GET /` returns `200` with the number of chunks (readiness probe) |
| Size | **H1** for the web process and both jobs, **DB1** for the database with this guide's documents |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DB_URL` | Yes | Runtime | Yes | Added by the internal connection: `postgres://<user>:<password>@<host>:<port>/<database>` |
| `OPENAI_API_KEY` | Yes | Runtime | Yes | Your OpenAI API key, for embeddings |
| `ANTHROPIC_API_KEY` | Yes | Runtime | Yes | Your Anthropic API key, for answers |
| `ASK_TOKEN` | Yes | Runtime | Yes | Generate with `openssl rand -hex 32`. Clients send it as `Authorization: Bearer <token>` |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

The internal connection also adds `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME` and `DB_PASSWORD`, which the application doesn't read.

## How the application works

![Ingestion: documents are split into chunks, an embeddings API turns each chunk into a vector, and the vectors are stored in Sevalla PostgreSQL with pgvector. Query: the question is embedded the same way, PostgreSQL finds the nearest chunks by cosine distance, and the chunks go with the question to an LLM, which answers.](https://assets.sevalla.com/brand/diagrams/docs/rag-pgvector-light.svg)

Ingestion runs as a job after each deployment:

1. It reads every `.md`, `.mdx` and `.txt` file in the repository's `docs` folder and skips the files that haven't changed since the last run.
2. It splits each changed file into chunks of up to 1,500 characters (about 375 tokens) that repeat up to 200 characters of the chunk before, so the text around each cut appears in both chunks.
3. It embeds the chunks with OpenAI's `text-embedding-3-small`, 100 chunks per request, and stores each chunk with its 1,536-dimension vector.

A question on `/ask` runs these steps:

1. The question is embedded with the same model.
2. PostgreSQL returns the 5 chunks closest to it by cosine distance: `ORDER BY embedding <=> $1 LIMIT 5`.
3. Claude Haiku 5.5 answers from those chunks only, citing them as `[1]` to `[5]`, and the answer streams to the client as server-sent events.

Embeddings come from OpenAI because Anthropic has no embeddings API. The two calls are separate, so you can change either provider without touching the other.

## 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).
* Python 3.14 and [uv](https://docs.astral.sh/uv/) on your computer, and Docker to run PostgreSQL with pgvector locally.
* An [OpenAI API key](https://platform.openai.com/api-keys) for embeddings and an [Anthropic API key](https://platform.claude.com/settings/keys) for answers. Set a monthly spending limit on both accounts: anyone with your application's token can spend from them.

## Create the project

Create the project with uv and add its packages:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
uv init --app --no-package --python 3.14 askdocs
cd askdocs
uv add "fastapi[standard]" "psycopg[binary,pool]" openai anthropic
rm main.py
mkdir app && touch app/__init__.py
echo ".env" >> .gitignore
```

`uv init` writes `pyproject.toml`, a `.python-version` with `3.14`, a `.gitignore` that keeps out `.venv`, and a Git repository. `uv add` installs FastAPI 0.162, psycopg 3.3, the OpenAI SDK 3.41 and the Anthropic SDK 2.3 into `.venv`, and pins every version in `uv.lock`:

| Package | What it does |
| - | - |
| `fastapi[standard]` | FastAPI with Uvicorn and the `fastapi` command |
| `psycopg[binary,pool]` | psycopg 3, the PostgreSQL driver, with its connection pool |
| `openai` | OpenAI's SDK, for embeddings |
| `anthropic` | Anthropic's SDK, for Claude's answers |

### Add the schema

The schema keeps one row per document, with a hash of its text, and one row per chunk, with its vector:

```sql app/schema.sql theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS documents (
    source text PRIMARY KEY,
    sha256 text NOT NULL
);

CREATE TABLE IF NOT EXISTS chunks (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    source text NOT NULL REFERENCES documents ON DELETE CASCADE,
    content text NOT NULL,
    embedding vector(1536) NOT NULL
);

CREATE INDEX IF NOT EXISTS chunks_embedding_idx
    ON chunks USING hnsw (embedding vector_cosine_ops);
```

* `vector(1536)` holds one embedding from `text-embedding-3-small`, which has 1,536 dimensions. A model with another size needs another column size.
* The HNSW index uses `vector_cosine_ops`, so it serves queries that sort by cosine distance, the `<=>` operator. OpenAI's embeddings have a length of 1, so cosine distance, Euclidean distance and inner product rank them the same way.
* The index exists before the first chunk does, so PostgreSQL adds each chunk to it as it's inserted, instead of building it from every row later. See [Scale the vector index](#scale-the-vector-index).
* Every statement can run more than once, and `CREATE EXTENSION IF NOT EXISTS` changes nothing when the extension exists.

A short script runs the file. psycopg runs a query of more than one statement in one `execute()` when the query has no parameters, so it runs the whole file at once:

```python app/migrate.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import os
from pathlib import Path

import psycopg

schema = Path(__file__).with_name("schema.sql").read_text()
with psycopg.connect(os.environ["DB_URL"], autocommit=True) as conn:
    conn.execute(schema)
print("The schema is up to date")
```

### Embed and store the documents

One function calls the embeddings API for both ingestion and questions, so they always use the same model:

```python app/embeddings.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
from openai import AsyncOpenAI

# 1,536 dimensions, the size of the vector column in schema.sql
MODEL = "text-embedding-3-small"

client = AsyncOpenAI()


async def embed(texts: list[str]) -> list[str]:
    """Return one vector per text, in pgvector's text format: '[0.012, -0.034, ...]'."""
    response = await client.embeddings.create(model=MODEL, input=texts)
    return [str(item.embedding) for item in response.data]
```

The OpenAI client reads `OPENAI_API_KEY` from the environment. `embed()` turns each vector into the text format pgvector reads, so the code needs no NumPy or pgvector package.

The ingestion script splits, embeds and stores the documents:

```python app/ingest.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import asyncio
import hashlib
import os
import sys
from pathlib import Path

import psycopg

from app.embeddings import embed

CHUNK_SIZE = 1500  # characters, about 375 tokens
OVERLAP = 200  # characters that each chunk repeats from the one before
BATCH_SIZE = 100  # chunks per embeddings request


def split(text: str) -> list[str]:
    """Split text into overlapping chunks, cut at a paragraph, line or word break."""
    chunks, start = [], 0
    while True:
        end = start + CHUNK_SIZE
        if end >= len(text):
            chunks.append(text[start:])
            return [chunk.strip() for chunk in chunks if chunk.strip()]
        for separator in ("\n\n", "\n", " "):
            cut = text.rfind(separator, start + CHUNK_SIZE // 2, end)
            if cut != -1:
                end = cut + len(separator)
                break
        chunks.append(text[start:end])
        # Start the next chunk at the first line or word in the overlap.
        overlap = end - OVERLAP
        boundary = text.find("\n", overlap, end)
        if boundary == -1:
            boundary = text.find(" ", overlap, end)
        start = boundary + 1 if boundary != -1 else overlap


async def ingest(folder: Path) -> None:
    files = sorted(p for p in folder.rglob("*") if p.suffix in {".md", ".mdx", ".txt"})
    sources = [p.relative_to(folder).as_posix() for p in files]
    async with await psycopg.AsyncConnection.connect(os.environ["DB_URL"], autocommit=True) as conn:
        for path, source in zip(files, sources):
            text = path.read_text()
            sha256 = hashlib.sha256(text.encode()).hexdigest()
            cursor = await conn.execute("SELECT sha256 FROM documents WHERE source = %s", (source,))
            if await cursor.fetchone() == (sha256,):
                continue  # unchanged since the last run

            chunks = [f"{source}\n\n{chunk}" for chunk in split(text)]
            vectors = []
            for i in range(0, len(chunks), BATCH_SIZE):
                vectors += await embed(chunks[i : i + BATCH_SIZE])

            async with conn.transaction():
                await conn.execute("DELETE FROM documents WHERE source = %s", (source,))
                await conn.execute(
                    "INSERT INTO documents (source, sha256) VALUES (%s, %s)", (source, sha256)
                )
                async with conn.cursor() as cursor:
                    await cursor.executemany(
                        "INSERT INTO chunks (source, content, embedding) VALUES (%s, %s, %s)",
                        [(source, chunk, vector) for chunk, vector in zip(chunks, vectors)],
                    )
            print(f"{source}: {len(chunks)} chunks")

        # Remove the documents whose files are gone, with their chunks.
        await conn.execute("DELETE FROM documents WHERE source <> ALL(%s)", (sources,))
        cursor = await conn.execute("SELECT count(*), count(DISTINCT source) FROM chunks")
        chunk_count, document_count = await cursor.fetchone()
        print(f"Ingested {chunk_count} chunks from {document_count} documents")


if __name__ == "__main__":
    asyncio.run(ingest(Path(sys.argv[1] if len(sys.argv) > 1 else "docs")))
```

* **Changed files only.** A file whose SHA-256 hash matches the stored one is skipped, so running the script again costs nothing for documents that didn't change. A changed file's old chunks are deleted and its new ones inserted in one transaction, so questions never see half a document.
* **Chunks.** `split()` cuts at the last paragraph break in a chunk's second half, or a line break, or a space, and starts the next chunk at a line or word inside the 200-character overlap. Each chunk starts with its file's path, which gives the embedding and the model the document's context.
* **Batches.** Each embeddings request carries up to 100 chunks, well under OpenAI's limit of 2,048 inputs per request, so a long document doesn't need a request per chunk.

### Answer questions

The application has two routes: `/` returns the number of chunks, and `/ask` streams an answer:

```python app/main.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import os
import secrets
from contextlib import asynccontextmanager
from typing import Annotated

from anthropic import AsyncAnthropic
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.sse import EventSourceResponse, ServerSentEvent
from psycopg_pool import AsyncConnectionPool
from pydantic import BaseModel, Field

from app.embeddings import embed

ANSWER_MODEL = "claude-haiku-5-5"
TOP_K = 5  # chunks sent to the model with each question

SEARCH = """
    SELECT source, content, embedding <=> %(vector)s::vector AS distance
    FROM chunks
    ORDER BY embedding <=> %(vector)s::vector
    LIMIT %(k)s
"""

INSTRUCTIONS = (
    "Answer the question from the numbered sources only. After each statement, cite its "
    "source number in brackets, such as [2]. If the sources don't answer the question, "
    "say that you don't know."
)

pool = AsyncConnectionPool(
    os.environ["DB_URL"], open=False, check=AsyncConnectionPool.check_connection
)
claude = AsyncAnthropic()


@asynccontextmanager
async def lifespan(app: FastAPI):
    await pool.open()
    yield
    await pool.close()


app = FastAPI(lifespan=lifespan)


def require_token(
    credentials: Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer())],
) -> None:
    if not secrets.compare_digest(credentials.credentials, os.environ["ASK_TOKEN"]):
        raise HTTPException(status_code=401, detail="Invalid token")


class Question(BaseModel):
    question: str = Field(min_length=1, max_length=2000)


@app.get("/")
async def index():
    async with pool.connection() as conn:
        cursor = await conn.execute("SELECT count(*) FROM chunks")
        (chunks,) = await cursor.fetchone()
    return {"chunks": chunks}


@app.post("/ask", response_class=EventSourceResponse, dependencies=[Depends(require_token)])
async def ask(body: Question):
    [vector] = await embed([body.question])
    async with pool.connection() as conn:
        cursor = await conn.execute(SEARCH, {"vector": vector, "k": TOP_K})
        rows = await cursor.fetchall()

    yield ServerSentEvent(
        event="sources",
        data=[
            {"n": n, "source": source, "distance": round(distance, 3)}
            for n, (source, _, distance) in enumerate(rows, start=1)
        ],
    )

    sources = "\n\n".join(
        f'<source n="{n}">\n{content}\n</source>' for n, (_, content, _) in enumerate(rows, start=1)
    )
    async with claude.messages.stream(
        model=ANSWER_MODEL,
        max_tokens=4096,
        output_config={"effort": "low"},
        system=INSTRUCTIONS,
        messages=[{"role": "user", "content": f"{sources}\n\nQuestion: {body.question}"}],
    ) as stream:
        async for text in stream.text_stream:
            yield ServerSentEvent(event="text", data=text)
        usage = (await stream.get_final_message()).usage
    yield ServerSentEvent(
        event="done", data={"input_tokens": usage.input_tokens, "output_tokens": usage.output_tokens}
    )
```

* **Search.** `ORDER BY embedding <=> %(vector)s::vector LIMIT 5` is the query the HNSW index serves: the distance operator in `ORDER BY`, ascending, with a `LIMIT`. The `::vector` cast turns the text from `embed()` into a vector.
* **Answer.** Claude gets the chunks as numbered `<source>` blocks and the instruction to cite them. The Anthropic client reads `ANTHROPIC_API_KEY` from the environment. `ANSWER_MODEL` is the place to change the model: Claude Haiku 5.5 is fast and costs $0.10 per million input tokens and $0.50 per million output tokens, and `claude-sonnet-5-5` answers harder questions for 20 times that. `effort` set to `low` keeps the model's thinking short, so the answer starts sooner.
* **Streaming.** `EventSourceResponse` sends each `yield` as a server-sent event: `sources` first, with the file and distance for each number, then a `text` event per piece of the answer, then `done` with the token counts. Each event's `data` is JSON. FastAPI sets `Cache-Control: no-cache` and `X-Accel-Buffering: no` on the response, and sends a `: ping` comment when 15 seconds pass without an event, so a slow answer never leaves the connection silent.
* **Token.** `/ask` spends money on every call, so it requires `Authorization: Bearer <token>` with the value of `ASK_TOKEN`. `secrets.compare_digest` compares the tokens in constant time. A request without the header gets `401` with `{"detail":"Not authenticated"}`.
* **Connections.** The pool opens when the application starts and closes when it stops. `check_connection` tests each connection before a request uses it and replaces a broken one, so the pool recovers by itself after a database restart.

### Add the documents

Put the files to answer from in a `docs` folder. To follow this guide with the same documents, download the Applications and Databases sections of Sevalla's documentation, which the site publishes as Markdown:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://docs.sevalla.com/llms.txt \
  | grep -oE 'docs\.sevalla\.com/(applications|databases)/[a-z0-9/-]+\.md' \
  | cut -d / -f 2- \
  | while read -r page; do
      mkdir -p "docs/$(dirname "$page")"
      curl -sf "https://docs.sevalla.com/$page" | sed -n '/^# /,$p' > "docs/$page"
    done
```

`llms.txt` lists every page. The `sed` command drops the lines above each page's title. The folder gets 39 files, about 480 KB, which split into 339 chunks.

### Run it on your computer

Start PostgreSQL with pgvector in Docker:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
docker run -d --name askdocs-db -p 5432:5432 -e POSTGRES_PASSWORD=askdocs pgvector/pgvector:pg18
```

Create a `.env` file with the database URL, your two API keys and a token of your choice for `/ask`. `.gitignore` keeps it out of the repository:

```text .env theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_URL=postgres://postgres:askdocs@localhost:5432/postgres
OPENAI_API_KEY=<your OpenAI API key>
ANTHROPIC_API_KEY=<your Anthropic API key>
ASK_TOKEN=local-token
```

Create the schema, ingest the documents and start the development server:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
uv run --env-file .env python -m app.migrate
uv run --env-file .env python -m app.ingest
uv run --env-file .env fastapi dev
```

The ingestion prints a line per document, then `Ingested 339 chunks from 39 documents`. In a second terminal, ask a question. `-N` makes curl print each event as it arrives:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -N http://127.0.0.1:8000/ask \
  -H "Authorization: Bearer local-token" \
  -H "Content-Type: application/json" \
  -d '{"question": "Can I turn on pgvector after I create a database?"}'
```

The response is a stream of events. It starts with the sources, then sends the answer in small pieces, and ends with the token counts:

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
event: sources
data: [{"n": 1, "source": "databases/overview.md", "distance": 0.466}, {"n": 2, "source": "databases/import-export/postgresql.md", "distance": 0.51}, {"n": 3, "source": "databases/create.md", "distance": 0.53}, {"n": 4, "source": "databases/create.md", "distance": 0.551}, {"n": 5, "source": "databases/import-export/postgresql.md", "distance": 0.574}]

event: text
data: "No."

event: text
data: " You"

event: text
data: " can turn on the pgv"

...

event: done
data: {"input_tokens": 2606, "output_tokens": 132}
```

Put together, the `text` events say that you can turn on the extension only when you create a database, citing `[3]`, `databases/create.md`. The wording changes from run to run.

Press `Ctrl+C` to stop the server. `docker rm -f askdocs-db` removes the database container.

## Prepare the project for Sevalla

The project already has what Ingot needs:

| File | Why Sevalla needs it |
| - | - |
| `.python-version` | Ingot builds on the Python line it names, 3.14. See [Pin the Python version](https://docs.sevalla.com/guides/python/overview.md#pin-the-python-version). |
| `uv.lock` | Ingot installs the exact versions in it with `uv sync --locked`, into a virtual environment at `/app/.venv` that's on the `PATH` of every process, jobs included. |
| `app/main.py` | Ingot finds `app = FastAPI(...)` in it and starts the web process with `uvicorn app.main:app --host 0.0.0.0 --port "$PORT"`. Sevalla sets `PORT` to `8080` by default. |
| `docs/` | The documents. They're part of the image, so the `Ingest` job reads the version of the files you deployed. |

The jobs need no files of their own: `python -m app.migrate` and `python -m app.ingest` are their start commands. Commit everything and push it to your Git provider. `uv init` already created the repository:

```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/askdocs.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 database**

In the sidebar, click **Databases**, then click **Create database**. Under **Database**, choose **PostgreSQL** and the newest version, and select **Enable pgvector extension**. Enter `askdocs-db` as the **Display name**, choose a **Data center** and a **Size**, then click **Create database**. DB1, the smallest size, holds this guide's documents; see [Scale the vector index](#scale-the-vector-index) for larger ones. You can turn the extension on only when you create the database.

**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 `askdocs-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 keys and the token**

Click the **Environment variables** tab, then click **Add environment variables**. Add `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` with your keys, and `ASK_TOKEN` with a long random value, for example the output of `openssl rand -hex 32`. Under **Availability**, keep **Available during runtime** selected and clear **Available during buildtime**: the build doesn't need them, and runtime-only values never reach the image. Click **Add environment variables**.

**5. Create the migration job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Migrate` as the **Name** and `python -m app.migrate` as the **Custom start command**. Keep the **Start policy** at **Before deployment** and the **Resource** at **H1**, then click **Create process**.

**6. Create the ingestion job**

Click **Create process** again. Choose **Job**, enter `Ingest` as the **Name** and `python -m app.ingest` as the **Custom start command**. Set the **Start policy** to **After successful deployment**, keep the **Resource** at **H1**, then click **Create process**.

**7. Deploy**

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

**8. 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 database form with PostgreSQL selected, the PostgreSQL version, and the Enable pgvector extension and Enable PostGIS extension checkboxes](https://assets.sevalla.com/screenshots/light/database-create.png)

The `Migrate` job runs before the new version starts, so the schema is ready when the web process starts, and a failed migration leaves the running version untouched. The `Ingest` job runs after the web process has deployed: if it fails, for example because an API key is wrong, the deployment is marked as failed but the new version keeps answering from the chunks already stored. See [Jobs](https://docs.sevalla.com/applications/processes.md#jobs).

## 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):

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ FastAPI · Python 3.14 ───────────────────────────────────────────────────────────────────╮
   │ Python           3.14                                                .python-version "3.14" │
   │ Package manager  uv                                                  uv.lock                │
   │ uv               0.13                                                default                │
   │ Framework        FastAPI                                             app/main.py            │
   │ Start            uvicorn app.main:app --host 0.0.0.0 --port "$PORT"  FastAPI                │
   ├─ Image ─────────────────────────────────────────────────────────────────────────────────────┤
   │ Build            python:3.14-slim-trixie                                                    │
   │ Runtime          python:3.14-slim-trixie                                                    │
   │ User             1000:1000                                                                  │
   │ Init             tini                                                                       │
   │ Port             $PORT, default 8080                                                        │
   ╰────────────────────────────────────────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

   After the build, the log shows `Migrate has been successful`, `Web has been deployed successfully`, then `Ingest has been successful`.
2. On the **Logs** tab, set **Process** to `Ingest`. The job printed a line per document, then `Ingested 339 chunks from 39 documents`.
3. Click the domain under the application's name, for example `askdocs-x7k2m.sevalla.app`. The response is `{"chunks":339}`.
4. Ask a question, with your application's domain. The command reads the token from your shell, so first run `export ASK_TOKEN=` followed by the value you set on Sevalla:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -N https://askdocs-x7k2m.sevalla.app/ask \
     -H "Authorization: Bearer $ASK_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"question": "Can I turn on pgvector after I create a database?"}'
   ```

   The stream starts with the same `sources` event as on your computer, `databases/create.md` among them, and the `text` events answer that you can turn the extension on only when you create a database. The events arrive while Claude writes, through Sevalla's proxy and Cloudflare.

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

### Check from a terminal

These checks need no dashboard. The first three use the API, with `API`, `H`, `APP_ID` and `DEPLOYMENT_ID` set as in [Deploy with the API](#deploy-with-the-api).

1. The deployment's status, from [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`):

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "$H" | jq -r .status
   ```

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

2. The jobs' results in the build log, from [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`). The messages carry terminal color codes, which `gsub` removes:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?search=has%20been%20successful" -H "$H" \
     | jq -r '.[].message | gsub("\u001b\\[[0-9;]*m"; "")'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ✅ Migrate has been successful
   ✅ Ingest has been successful
   ```

3. The ingestion's last line, from [Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`), which searches the last hour by default:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$APP_ID/runtime-logs?search=Ingested" -H "$H" | jq -r '.[].message'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Ingested 339 chunks from 39 documents
   ```

4. The application, with your domain:

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

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"chunks":339}
   ```

5. `/ask` without the token:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -X POST https://askdocs-x7k2m.sevalla.app/ask \
     -H "Content-Type: application/json" -d '{"question": "test"}'
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"detail":"Not authenticated"}
   ```

6. `/ask` with the token, keeping only the first and last event types:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -sN https://askdocs-x7k2m.sevalla.app/ask \
     -H "Authorization: Bearer $ASK_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"question": "Can I turn on pgvector after I create a database?"}' \
     | grep -E '^event: (sources|done)'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   event: sources
   event: done
   ```

## Add a health check

Without a readiness probe, a new instance receives traffic as soon as its container starts, before Uvicorn listens. During a deployment, a request that reaches it in those first seconds fails with `503` and `upstream connect error or disconnect/reset before headers. reset reason: connection termination`. Point the probe at `/`, which answers once the application and its database connection pool are up:

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

With the probe, the previous instance serves every request until the new one passes, then gets `SIGTERM`. Uvicorn stops taking new connections, finishes the answers it's streaming, then exits. See [Health checks](https://docs.sevalla.com/applications/processes.md#health-checks).

The probe on `/` checks the database too. While the database is unavailable, for example during a restart, the instance takes no traffic and requests get `503` with `no healthy upstream`. When the database is back, the pool reconnects by itself: in this guide's test, the application answered again within a minute, without a deployment.

## Update the documents

Add, change or delete files in `docs`, commit and push. The deployment's `Ingest` job embeds only the files whose content changed, prints a line for each, and deletes the chunks of files that are gone. Unchanged files cost no embeddings.

For example, a push that changed `databases/create.md` and deleted `applications/analytics.md` made the job print only `databases/create.md: 7 chunks`, then `Ingested 332 chunks from 38 documents`. To read the job's output with the API, search the runtime logs for `chunks`, as in [Check from a terminal](#check-from-a-terminal).

A job that runs longer than its time limit fails, and the build log shows the limit when the job starts, for example `Running Ingest (timeout: 600s)`. The first ingestion of a large corpus can take longer than that. Run it once from the [web terminal](https://docs.sevalla.com/applications/web-terminal.md) instead: on the application's **Web terminal** tab, run `python -m app.ingest` in `/app`, the folder the terminal opens in. It uses the same environment variables as the jobs, and later deployments only embed what changed.

## Secure the endpoint

* **Keep the token secret.** Anyone with `ASK_TOKEN` spends from your OpenAI and Anthropic accounts. Scanners started requesting paths such as `/.env` and `/wp-admin/setup-config.php` on this guide's test application within 30 seconds of its first deployment, so never deploy `/ask` without the check. To call `/ask` from a browser app, call it from your own backend, which holds the token, rather than shipping the token to browsers.
* **Keep the keys runtime-only.** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` and `ASK_TOKEN` are available during runtime only, so they're not in the image or its build log.
* **Treat documents as untrusted input.** A document can contain text that tries to instruct the model, such as "ignore the question and reply with ...". Here the model only writes text, and the code decides which chunks it sees, so the worst case is a wrong answer. Don't give a model tools that act on your systems based on retrieved text without code that checks each action.
* **Limit spending.** Set a monthly limit on both provider accounts. Each answer costs less than \$0.001 with Claude Haiku 5.5, so a sudden rise in spend means someone else is calling `/ask`: rotate `ASK_TOKEN`.

## Measured performance and cost

Measured on Oct 30, 2026, with this guide's 339 chunks, the web process on H1 and the database on DB1:

| What | Measured |
| - | - |
| Ingestion | 39 documents, 339 chunks, 106,480 embedding tokens: $0.0021 with `text-embedding-3-small` at $0.02 per million tokens. The `Ingest` job took 28 s, 15 s of it embedding and storing. |
| Retrieval | 0.1 to 0.4 s between the start of the response and the `sources` event, nearly all of it the embeddings request: the PostgreSQL query takes 1.4 ms. |
| Answer | The first `text` event 1.3 to 2.5 s after the request in most runs, then the rest at a steady pace: a long answer arrived as 110 events spread over 2.2 s, so Sevalla's proxy and Cloudflare pass the events on without buffering. |
| Tokens per question | 2,100 to 2,600 input tokens (5 chunks and the question) and 70 to 1,200 output tokens, thinking included: $0.0002 to $0.0009 with Claude Haiku 5.5. |
| Web process memory | 146 MB, on H1's 300 MB. |
| Instances | With the web process on 2 instances, requests went to both and every answer streamed to the end. Each answer streams over the request that asked for it, and the process keeps nothing between requests, so it runs on any number of instances. |
| Database size | 6 MB for 339 chunks: about 9.8 KB a chunk in the table (the 6 KB vector and the text) and 8 KB in the HNSW index. |

## Scale the vector index

With a few hundred chunks, PostgreSQL reads the whole table instead of the HNSW index, because that's faster: the query plan shows `Seq Scan on chunks`, and the search takes 1.4 ms. The index pays off as the table grows. With 10,000 chunks on DB1:

| | Search time |
| - | - |
| Without an index (whole table) | 401 ms |
| With the HNSW index | 1.9 ms |
| With the HNSW index, the first query after a restart | 321 ms, while PostgreSQL reads the index into memory |

The test table held 10,000 random vectors with text as long as this guide's chunks.

* **Storage.** Plan for about 17 KB a chunk with 1,500-character chunks: 10,000 chunks took 95 MB of table and 78 MB of index. Check the database's storage on its **Analytics** tab, and move to a larger [size](https://docs.sevalla.com/databases/overview.md#sizes) before it fills.
* **Memory.** Searches are fast when the index fits in the database's memory. DB1 has 0.25 GB; DB2 has 2 GB.
* **Loading many chunks.** Because the migration creates the index on the empty table, each insert also updates the index, which is what an update of a few documents needs. A first load of thousands of chunks costs CPU either way: on DB1, inserting 10,000 chunks into the indexed table took 331 s, and inserting them without the index took 7 s plus 237 s to build it afterwards. DB1 gives an index build 15 MB of memory (`maintenance_work_mem`), so the build prints `hnsw graph no longer fits into maintenance_work_mem after 2288 tuples` and slows down. Don't raise `maintenance_work_mem` on DB1: with 100 MB, the build took 612 s instead of 237 s, the database stopped accepting new connections, and it failed its liveness probe, which restarts it. Give a large first load a larger database size, with more CPU and memory than DB1.
* **Other embedding models.** The column's size must match the model. A vector of another size fails with `expected 1536 dimensions, not 3072`, and an HNSW index on a `vector` column takes up to 2,000 dimensions, so `vector(3072)` for `text-embedding-3-large` fails with `column cannot have more than 2000 dimensions for hnsw index`. Pass `dimensions=1536` to `client.embeddings.create()` to get shorter vectors from that model. After you change the model, delete the rows in `documents` so the next run embeds everything again.
* **pgvector version.** HNSW indexes need pgvector 0.5.0 or later. Check the version your database runs with `SELECT extversion FROM pg_extension WHERE extname = 'vector';` in the database's [SQL studio](https://docs.sevalla.com/databases/studio.md).

## Choose between pgvector and Qdrant

pgvector keeps the vectors in the database you already run. [Qdrant](https://docs.sevalla.com/guides/ai/qdrant.md) is a separate vector database that you deploy as an application:

| | pgvector in Sevalla PostgreSQL | Qdrant on Sevalla |
| - | - | - |
| What you run | An extension in the managed database | An application from Qdrant's Docker image, with a disk and HTTP traffic turned off |
| Backups | The database's [backups](https://docs.sevalla.com/databases/backups.md) | Daily backups of the disk, and Qdrant snapshots in object storage |
| Vectors and your data | One database: join chunks with your tables, filter with SQL, change both in one transaction | A separate store that you keep in step with your database |
| Growing | A larger database size, with more memory for the index | A larger instance size: a process with a [disk](https://docs.sevalla.com/applications/disks.md) runs 1 instance, and each deployment stops it for about 20 seconds |
| Fits when | You already use PostgreSQL, and the index fits in the database's memory, at about 8 KB a chunk | Vector search is the main workload, or you need what Qdrant adds: filters inside the search, quantization, vectors kept on the disk |

## 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 python:3.14-slim-trixie AS build
COPY --from=ghcr.io/astral-sh/uv:0.13 /uv /usr/local/bin/
ENV UV_COMPILE_BYTECODE=1 \
    UV_PYTHON_DOWNLOADS=0
WORKDIR /app
COPY pyproject.toml uv.lock .python-version ./
RUN uv sync --locked --no-dev

FROM python:3.14-slim-trixie
RUN useradd --uid 1000 --create-home app
ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PORT=8080 \
    FORWARDED_ALLOW_IPS="*"
WORKDIR /app
COPY --from=build /app/.venv .venv
COPY . .
RUN python -m compileall -q app
USER 1000:1000
EXPOSE 8080
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port \"$PORT\""]
```

| Line or choice | Why |
| - | - |
| `python:3.14-slim-trixie` | The Python line that `.python-version` pins, on Debian 13, as Ingot builds it. A line tag picks up each patch release and security fix when you deploy again; a digest would stay on one release. |
| Two stages | The first stage installs the packages with uv. The final image gets the virtual environment, the code and the documents, not uv. |
| `ghcr.io/astral-sh/uv:0.13` | uv's official image, which Ingot uses too. `COPY --from` takes its `uv` binary without a download script. |
| `uv sync --locked --no-dev` | Installs exactly what `uv.lock` pins, and fails if the lock file doesn't match `pyproject.toml`. `UV_COMPILE_BYTECODE=1` compiles the packages at build time, so instances start faster. `UV_PYTHON_DOWNLOADS=0` makes uv use the image's Python. |
| Lock files before the code | Docker reuses the installed packages until `pyproject.toml`, `uv.lock` or `.python-version` change, so a change to the code or the documents rebuilds only the last layers. |
| `useradd` and `USER 1000:1000` | Runs the application as an unprivileged user with a home directory, the same user ID as Ingot's images. The code stays owned by root, so the application can't change it; `compileall` compiles it at build time for that reason. |
| `FORWARDED_ALLOW_IPS="*"` | Uvicorn trusts the `X-Forwarded-Proto` and `X-Forwarded-For` headers from Sevalla's proxy. Ingot sets the same. |
| `sh -c "exec uvicorn ..."` | A shell puts `PORT` on Uvicorn's command line, and `exec` replaces the shell with Uvicorn, which runs as PID 1, handles `SIGTERM` and finishes the streams in progress before it exits. |

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.venv
**/__pycache__
.env*
Dockerfile
.dockerignore
```

The `.dockerignore` keeps your local `.env` and its keys out of the image. 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 `6 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` and `Ingest has been successful`: the jobs run `python -m app.migrate` and `python -m app.ingest` in the new image, and the ingestion skips every document because none changed. The domain still returns `{"chunks":339}` and `/ask` answers as before. The deployment's **Dockerfile** tab shows your Dockerfile. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md).

To switch with the API, send the same settings to [Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`), with `API`, `H` and `APP_ID` set as in [Deploy with the API](#deploy-with-the-api):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH "$API/applications/$APP_ID" -H "$H" -H "Content-Type: application/json" \
  -d '{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}' | jq -r .build_type
```

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

Then trigger a deployment and wait for it as in that section, and check its build log:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?search=using%20Dockerfile" -H "$H" | jq -r '.[].message'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
📦 Building and pushing docker image using Dockerfile...
```

CLI: `sevalla apps update $APP_ID --build-type dockerfile --dockerfile-path Dockerfile`. MCP: `sevalla_api_update` with `updateApplicationV3` and the same body.

## 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 `Migrate`, replaces the web process's instances, then runs `Ingest`. For a schema change, add statements to `app/schema.sql` that can run more than once, such as `ALTER TABLE chunks ADD COLUMN IF NOT EXISTS title text`. After you add a package with `uv add`, commit `pyproject.toml` and `uv.lock` together. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Add a custom domain

To serve the API from your own domain, such as `ask.example.com`, add the domain on the application's **Domains** tab and create the DNS records that Sevalla shows. No code change is needed. 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 `Migrate` job fails with `permission denied to create extension "vector"` and `Must be superuser to create this extension.` | The database was created without **Enable pgvector extension**, which you can turn on only when you create a database. | Create a database with the extension turned on, connect it to the application, remove the connection to the old one, and deploy. The `Ingest` job fills the new database. |
| The `Migrate` job fails with `password authentication failed for user "askdocs"` | `DB_URL` holds an old password, for example after the database's password changed. | Put the new password in `DB_URL` and `DB_PASSWORD`, then deploy. See [Change the database password](https://docs.sevalla.com/databases/connect.md#change-the-database-password). |
| `/ask` returns `401` with `{"detail":"Not authenticated"}` or `{"detail":"Invalid token"}` | The request has no `Authorization` header, or its token isn't the value of `ASK_TOKEN`. | Send `Authorization: Bearer <token>` with the value on the application's **Environment variables** tab. |
| The `Ingest` job fails, and its logs on the **Logs** tab show `insufficient_quota` | The OpenAI account has no credit left. | Add credit on the OpenAI account, then deploy again. |
| Inserting a chunk fails with `expected 1536 dimensions, not 3072` | The embedding model returns vectors of another size than the `vector(1536)` column. | Use a model with 1,536 dimensions, or request them with `dimensions=1536`. See [Scale the vector index](#scale-the-vector-index). |
| [Create application](https://api-docs.sevalla.com/v3/applications/create-application) returns `400` with `Cannot access repository <url>: Repository not found or you don't have access.` | No Git account connected to Sevalla can read the repository, or Sevalla's GitHub App has no access to it. | Connect a GitHub account with access to the repository at `https://app.sevalla.com/my-account`, and add the repository under **Repository access** in the GitHub App's settings, which the message links to. |
| Requests fail with `503` and `upstream connect error or disconnect/reset before headers` during deployments | A new instance receives traffic before Uvicorn listens. | [Add a health check](#add-a-health-check). |
| Requests fail with `503` and `no healthy upstream` | The readiness probe on `/` fails because the database is unavailable, for example while it restarts. | Check the database's status. Once it's back, the pool reconnects and the application answers again, without a deployment. |
| Answers say the sources don't cover a question you know the documents answer | The 5 closest chunks miss the passage, often because a chunk mixes two or more topics. | Read the `sources` event to see which files matched. Write the question with the documents' terms, raise `TOP_K`, or lower `CHUNK_SIZE` and re-ingest. |

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

## Deploy with the API

The same deployment as [Deploy to Sevalla](#deploy-to-sevalla), as calls to the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) that a script or an AI agent runs in order. Each step names the matching [CLI](https://docs.sevalla.com/developer-tools/cli.md) command and [Sevalla MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool. The sequence needs:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, or the capabilities to create applications and databases, in `SEVALLA_API_KEY`.
* The repository pushed to GitHub, and a GitHub account with access to it connected to Sevalla. The API has no call that reports the connection: if no connected account can read the repository, the step that creates the application fails with `400` and a message that starts with `Cannot access repository` and links to where to connect one.
* `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` set in the shell, plus `curl`, `jq` and `openssl`.

Set the base URL, the authorization header and the repository:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
API=https://api.sevalla.com/v3
H="Authorization: Bearer $SEVALLA_API_KEY"
REPO_URL=https://github.com/acme/askdocs
BRANCH=main
```

**1. Choose the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns every location with its region ID in `location`, such as `eu-frankfurt-1` for Frankfurt, Germany. Pick one by region ID, not by `display_name`, which differs between environments. [Locations](https://docs.sevalla.com/platform/locations.md#locations-on-oracle-cloud-infrastructure) lists the region IDs:

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

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

**2. Look up the sizes and the PostgreSQL version**

[List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`), [List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) and [List database versions](https://api-docs.sevalla.com/v3/resources/list-database-versions) (`listDatabaseVersionsV3`) give the IDs of **DB1** and **H1** and the newest PostgreSQL version in the location:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB1_ID=$(curl -s "$API/resources/database-resource-types" -H "$H" | jq -r '.[] | select(.name == "db1") | .id')
H1_ID=$(curl -s "$API/resources/process-resource-types" -H "$H" | jq -r '.[] | select(.name == "h1") | .id')
PG_VERSION=$(curl -s "$API/resources/database-versions?cluster_id=$CLUSTER_ID" -H "$H" \
  | jq -r '.[] | select(.type == "postgresql") | .versions[-1]')
```

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

**3. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) with `extensions.enable_vector` set to `true`, the API's name for **Enable pgvector extension**. You choose the password, and the API never returns it, so keep it in a variable for the connection URL:

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

The database starts as `creating`. Wait for `ready`, with [Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`):

```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 "$H" | jq -r .status)" = ready ] && break; sleep 10; done
```

CLI: not available, because `sevalla databases create` has no option for the extension. MCP: `create_database` with `extensions: {"vector": true}`; it generates a password it never returns, so pass `connect_to_application_id` and `env_var_name: "DB_URL"` once the application exists, and leave the `DB_*` variables out of the environment variables step. The MCP server writes the URL as `postgresql://...`, which psycopg reads as well as `postgres://`.

**4. Create the application**

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(curl -s -X POST "$API/applications" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg cluster "$CLUSTER_ID" --arg repo "$REPO_URL" --arg branch "$BRANCH" \
    '{display_name: "askdocs", cluster_id: $cluster, source: "privateGit", git_type: "github",
      repo_url: $repo, default_branch: $branch}')" \
  | jq -r .id)
```

CLI: `sevalla apps create --name askdocs --cluster $CLUSTER_ID --source privateGit --git-type github --repository $REPO_URL --branch $BRANCH`. MCP: `create_application` with `deploy: false`.

**5. Size the web process and add the readiness probe**

The API creates the web process at **S1**, not the dashboard's **H1**. [List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) finds it, and [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets **H1** and the [readiness probe](#add-a-health-check). Send `timeoutSeconds`: a probe set through the API without it waits 120 seconds, not the dashboard's 1:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID=$(curl -s "$API/applications/$APP_ID/processes" -H "$H" | jq -r '.data[] | select(.type == "web") | .id')
curl -s -X PATCH "$API/applications/$APP_ID/processes/$WEB_ID" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg size "$H1_ID" \
    '{resource_type_id: $size, readiness_probe: {httpGet: {path: "/", port: 8080},
      initialDelaySeconds: 0, periodSeconds: 10, timeoutSeconds: 1, successThreshold: 1, failureThreshold: 3}}')" \
  | jq -c '{resource_type_id, readiness_probe}'
```

CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID`; the CLI has no option for the probe. MCP: `scale_process` with `size_id` for the size, and `sevalla_api_update` with `updateProcessV3` and the same body for the probe.

**6. Connect the database**

[Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`):

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

Unlike the dashboard, the API doesn't add the connection's environment variables. The next step adds them.

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.

**7. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`) adds one variable per call. The loop adds the connection's variables in the dashboard's names and format, from [Get database](https://api-docs.sevalla.com/v3/databases/get-database), then the API keys and a new `ASK_TOKEN`, all for runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_HOST=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r .internal_hostname)
DB_PORT=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r .internal_port)
ASK_TOKEN=$(openssl rand -hex 32)
for pair in "DB_HOST=$DB_HOST" "DB_PORT=$DB_PORT" "DB_DATABASE=askdocs" "DB_USERNAME=askdocs" \
  "DB_PASSWORD=$DB_PASSWORD" "DB_URL=postgres://askdocs:$DB_PASSWORD@$DB_HOST:$DB_PORT/askdocs" \
  "OPENAI_API_KEY=$OPENAI_API_KEY" "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" "ASK_TOKEN=$ASK_TOKEN"; do
  curl -s -X POST "$API/applications/$APP_ID/env-vars" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --arg key "${pair%%=*}" --arg value "${pair#*=}" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}')" \
    | jq -r .key
done
```

It prints each key as it's added. [List environment variables](https://api-docs.sevalla.com/v3/applications/list-environment-variables) (`listEnvironmentVariablesV3`) returns the values, so you can read `ASK_TOKEN` back later; the MCP server's tools withhold them.

CLI: `sevalla apps env-vars create $APP_ID --key ASK_TOKEN --value $ASK_TOKEN`, once per variable; CLI 2.1.0 makes each variable available during build and runtime, with no option for runtime only. MCP: `set_environment_variables` with `runtime: true` and `buildtime: false` for each variable.

**8. Create the two jobs**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) needs the size, a `scaling_strategy` and, for a job, its `job_start_policy`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for job in "Migrate|python -m app.migrate|beforeDeployment" "Ingest|python -m app.ingest|afterSuccessDeployment"; do
  IFS='|' read -r name command policy <<< "$job"
  curl -s -X POST "$API/applications/$APP_ID/processes" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --arg name "$name" --arg command "$command" --arg policy "$policy" --arg size "$H1_ID" \
      '{display_name: $name, type: "job", entrypoint: $command, resource_type_id: $size, job_start_policy: $policy,
        scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
    | jq -c '{display_name, job_start_policy}'
done
```

CLI: `sevalla apps processes create $APP_ID --name Migrate --type job --resource-type $H1_ID --command "python -m app.migrate" --job-start-policy beforeDeployment`, and the same for `Ingest` with `afterSuccessDeployment`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**9. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg branch "$BRANCH" '{branch: $branch}')" | jq -r .id)
```

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

**10. Wait for the deployment**

Poll [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until the status is `success` or `failed`. The status reaches `success` after the `Ingest` job, 2 to 3 minutes after the trigger:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
while true; do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "$H" | jq -r .status)
  echo "$STATUS"
  case $STATUS in success|failed|cancelled) break ;; esac
  sleep 15
done
```

On `failed`, read the build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) and see [Troubleshoot](#troubleshoot). CLI: `sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID` and `sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment` and `get_deployment_logs`.

**11. 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 "$H" -H "Content-Type: application/json" \
  -d '{"auto_deploy": true}' | jq -r .auto_deploy
```

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

**12. Find the domain**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the `sevalla.app` domain as the primary one:

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

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"chunks":339}
```

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

Then run the rest of the checks in [Check from a terminal](#check-from-a-terminal), with `https://$APP_HOST` as the domain.


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