> ## 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 hybrid search with PostgreSQL

> Combine PostgreSQL full-text search and pgvector similarity with reciprocal rank fusion in a FastAPI search API on Sevalla, so exact terms and plain questions both find the right document.

Hybrid search runs two searches over the same text and merges their rankings into one: PostgreSQL full-text search finds the chunks that contain the words of the query, pgvector finds the chunks closest to it in meaning, and reciprocal rank fusion (RRF) combines both lists in the same SQL query. This guide builds a search API with it in FastAPI and deploys it to Sevalla as one application on a PostgreSQL database with pgvector: a job creates the schema and both indexes before each deployment, a second job embeds the documents in your repository after each deployment, and the web process answers on `/search`.

Each search alone misses queries the other finds. In this guide's tests, vector search didn't return the only page that mentions `BackoffLimitExceeded` among its first 20 results, and full-text search returned nothing at all for `my app ran out of memory and keeps restarting`. Hybrid search ranked the right page first for both. The project uses the stack and the ingestion of [Build RAG with pgvector](https://docs.sevalla.com/guides/ai/rag-pgvector.md), so the query can also replace the vector-only search there: see [Add hybrid search to the RAG application](#add-hybrid-search-to-the-rag-application).

## 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 API 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 |
| `SEARCH_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 hybrid search works

Every chunk of text is stored once, with two representations and an index on each:

| Search | Column and index | Finds | Misses |
| - | - | - | - |
| Full-text (keyword) | `tsv`, a `tsvector` generated from the text, with a GIN index | Chunks that contain every word of the query, after stemming: identifiers, error codes, product names | Questions worded differently from the text, and queries with a word the chunk lacks |
| Vector (semantic) | `embedding`, a 1,536-dimension vector from OpenAI's `text-embedding-3-small`, with an HNSW index | Chunks that mean the same as the query, whatever the wording | Rare exact terms the embedding model doesn't know, such as `fuzzystrmatch` |

A search runs both and merges them with reciprocal rank fusion:

1. Full-text search returns the 20 chunks that match the query best, ranked by `ts_rank_cd`. The query goes through `websearch_to_tsquery`, so it supports the syntax of a web search engine: `"exact phrase"`, `-word` to exclude a word, and `or`.
2. Vector search returns the 20 chunks closest to the query's embedding, by cosine distance.
3. Each chunk gets a score of `1 / (60 + rank)` from each list it appears in, and the scores add up. A chunk ranked first in one list scores `0.0164`; a chunk ranked first in both scores `0.0328`, so chunks that both searches find move to the top. When scores tie, the keyword match comes first.

RRF uses only the positions in each list, not the raw scores, so it needs no tuning to compare a text rank with a cosine distance. `60` is the constant from the original RRF paper; a smaller one gives the first few results of each list more weight.

## 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. Set a monthly spending limit on the account: every search sends the query to the embeddings API.

## 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 docsearch
cd docsearch
uv add "fastapi[standard]" "psycopg[binary,pool]" openai
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 and the OpenAI SDK 3.41 into `.venv` and pins every version in `uv.lock`.

### Add the schema

```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,
    tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED
);

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

CREATE INDEX IF NOT EXISTS chunks_tsv_idx
    ON chunks USING gin (tsv);
```

* **`tsv`** is a generated column: PostgreSQL computes it from `content` on every insert and update, so the code never writes it. `to_tsvector('english', ...)` splits the text into words, drops stop words such as `the` and `and`, and reduces each word to its stem, so `restarting` matches `restart`. For text in another language, use that language's configuration, such as `'german'`, in the column and in the query; `SELECT cfgname FROM pg_ts_config;` lists them.
* **`chunks_tsv_idx`**, a GIN index, maps each stem to the chunks that contain it, so full-text search doesn't read the whole table.
* **`chunks_embedding_idx`**, an HNSW index with `vector_cosine_ops`, serves the vector search, which sorts by cosine distance (`<=>`).
* Every statement can run more than once.

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

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

These two files are the same as in [Build RAG with pgvector](https://docs.sevalla.com/guides/ai/rag-pgvector.md#embed-and-store-the-documents), which explains them in detail. One function calls the embeddings API for both ingestion and search, 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 ingestion script splits each file in `docs` into chunks of up to 1,500 characters that overlap by up to 200, embeds the chunks 100 per request, and stores them. It skips files whose SHA-256 hash hasn't changed since the last run and deletes the chunks of files that are gone:

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

Each chunk starts with its file's path, so the path is part of both the embedding and the `tsv` column, and a search for a file name finds it.

### Search

The application has two routes: `/` returns the number of chunks, and `/search` runs the hybrid query:

```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 fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool

from app.embeddings import embed

CANDIDATES = 20  # results each search contributes to the fusion
RRF_K = 60  # reciprocal rank fusion constant

SEARCH = """
WITH semantic AS (
    SELECT id, rank() OVER (ORDER BY embedding <=> %(vector)s::vector) AS rank
    FROM chunks
    ORDER BY embedding <=> %(vector)s::vector
    LIMIT %(candidates)s
),
keyword AS (
    SELECT id, rank() OVER (ORDER BY ts_rank_cd(tsv, query) DESC) AS rank
    FROM chunks, websearch_to_tsquery('english', %(q)s) AS query
    WHERE tsv @@ query
    ORDER BY ts_rank_cd(tsv, query) DESC
    LIMIT %(candidates)s
),
fused AS (
    SELECT id, semantic.rank AS semantic_rank, keyword.rank AS keyword_rank,
           coalesce(1.0 / (%(rrf_k)s + semantic.rank), 0)
         + coalesce(1.0 / (%(rrf_k)s + keyword.rank), 0) AS score
    FROM semantic
    FULL OUTER JOIN keyword USING (id)
)
SELECT source, content, semantic_rank, keyword_rank, round(score, 4)::float AS score
FROM fused
JOIN chunks USING (id)
ORDER BY fused.score DESC, keyword_rank
LIMIT %(limit)s
"""

pool = AsyncConnectionPool(
    os.environ["DB_URL"],
    open=False,
    check=AsyncConnectionPool.check_connection,
    kwargs={"row_factory": dict_row},
)


@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["SEARCH_TOKEN"]):
        raise HTTPException(status_code=401, detail="Invalid token")


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


@app.get("/search", dependencies=[Depends(require_token)])
async def search(
    q: Annotated[str, Query(min_length=1, max_length=500)],
    limit: Annotated[int, Query(ge=1, le=20)] = 5,
):
    [vector] = await embed([q])
    params = {"vector": vector, "q": q, "candidates": CANDIDATES, "rrf_k": RRF_K, "limit": limit}
    async with pool.connection() as conn:
        cursor = await conn.execute(SEARCH, params)
        return {"query": q, "results": await cursor.fetchall()}
```

* **One query.** `semantic` and `keyword` each rank their 20 best chunks with `rank()`, `fused` joins them with a `FULL OUTER JOIN`, so a chunk that only one search found keeps a score, and the final `SELECT` adds the text. PostgreSQL runs it in one round trip, and the HNSW and GIN indexes serve the two searches as they would alone.
* **Ranks in the response.** Each result carries `semantic_rank` and `keyword_rank`, `null` when that search didn't return the chunk, so you can see which search found it.
* **Token.** `/search` calls the embeddings API on every request, so it requires `Authorization: Bearer <token>` with the value of `SEARCH_TOKEN`. A request without the header gets `401` with `{"detail":"Not authenticated"}`, and a wrong token gets `{"detail":"Invalid token"}`.
* **Input limits.** `q` takes 1 to 500 characters and `limit` 1 to 20 results; anything else gets `422`.
* **Connections.** The pool opens when the application starts, and `check_connection` replaces a broken connection before a request uses it, for example after a database restart.

### Add the documents

Put the files to search 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
```

The folder gets 39 files, about 480 KB, which split into 341 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 docsearch-db -p 5432:5432 -e POSTGRES_PASSWORD=docsearch pgvector/pgvector:pg18
```

Create a `.env` file with the database URL, your OpenAI API key and a token of your choice. `.gitignore` keeps it out of the repository:

```text .env theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_URL=postgres://postgres:docsearch@localhost:5432/postgres
OPENAI_API_KEY=<your OpenAI API key>
SEARCH_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 341 chunks from 39 documents`. In a second terminal, search for an error name that appears on one page:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -G http://127.0.0.1:8000/search \
  -H "Authorization: Bearer local-token" \
  --data-urlencode "q=BackoffLimitExceeded" \
  | jq -c '.results[] | {source, semantic_rank, keyword_rank, score}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"source":"applications/processes.md","semantic_rank":null,"keyword_rank":1,"score":0.0164}
{"source":"applications/troubleshooting/http-errors.md","semantic_rank":1,"keyword_rank":null,"score":0.0164}
{"source":"databases/troubleshooting.md","semantic_rank":2,"keyword_rank":null,"score":0.0161}
{"source":"applications/troubleshooting/runtime-errors.md","semantic_rank":3,"keyword_rank":null,"score":0.0159}
{"source":"applications/troubleshooting/http-errors.md","semantic_rank":4,"keyword_rank":null,"score":0.0156}
```

Only full-text search found `applications/processes.md`, the page that explains the error: vector search ranked other troubleshooting pages first and left it out of its 20 results. Hybrid search puts it first. Each result also has a `content` field with the chunk's text, which the `jq` filter leaves out.

Press `Ctrl+C` to stop the server. `docker rm -f docsearch-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"`. |
| `docs/` | The documents. They're part of the image, so the `Ingest` job reads the version of the files you deployed. |

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/docsearch.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 `docsearch-db` as the **Display name**, choose a **Data center** and the **Size** **DB1**, then click **Create database**. You can turn the extension on only when you create a 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**, then click **Create application**.

**3. Connect the database**

Click the **Networking** tab. Under **Connected services**, click **Add internal connection**. Choose `docsearch-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.

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

Click the **Environment variables** tab, then click **Add environment variables**. Add `OPENAI_API_KEY` with your key and `SEARCH_TOKEN` with the output of `openssl rand -hex 32`. Under **Availability**, keep **Available during runtime** selected and clear **Available during buildtime**, then click **Add environment variables**.

**5. Create the two jobs**

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**. Create a second job named `Ingest` with the command `python -m app.ingest` and the **Start policy** **After successful deployment**.

**6. Add the readiness probe**

On the **Processes** tab, click **⋯** on the **Web** process, then click **Update process**. Under **Health checks**, turn on **Readiness probe**, set the **Path** to `/` and keep the **Port** at `8080`. Click **Update process**. The probe keeps the previous instance serving until the new one answers, which `/` does once the database connection pool is open.

**7. Deploy**

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

The `Migrate` job runs before the new version starts, so the columns and indexes exist when the web process starts. The `Ingest` job runs after the web process has deployed. 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 341 chunks from 39 documents`.
3. Click the domain under the application's name, for example `docsearch-x7k2m.sevalla.app`. The response is `{"chunks":341}`.

### Check from a terminal

The first two checks use the API, with `API`, `H`, `APP_ID` and `DEPLOYMENT_ID` set as in [Deploy with the API](#deploy-with-the-api). The searches read the token from your shell: run `export SEARCH_TOKEN=` followed by the value you set on Sevalla.

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

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

2. 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; `.[0]` prints one line when several deployments ran:

   ```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 '.[0].message'
   ```

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

3. The application, with your domain:

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

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

4. `/search` without the token:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "https://docsearch-x7k2m.sevalla.app/search?q=test"
   ```

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

5. A search for an exact term, which full-text search finds and vector search misses:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -G https://docsearch-x7k2m.sevalla.app/search \
     -H "Authorization: Bearer $SEARCH_TOKEN" \
     --data-urlencode "q=BackoffLimitExceeded" \
     | jq -c '.results[0] | {source, semantic_rank, keyword_rank}'
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"source":"applications/processes.md","semantic_rank":null,"keyword_rank":1}
   ```

6. A question in your own words, which no chunk matches word for word, so only vector search finds it:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -G https://docsearch-x7k2m.sevalla.app/search \
     -H "Authorization: Bearer $SEARCH_TOKEN" \
     --data-urlencode "q=my app ran out of memory and keeps restarting" \
     | jq -c '.results[0] | {source, semantic_rank, keyword_rank}'
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"source":"applications/troubleshooting/runtime-errors.md","semantic_rank":1,"keyword_rank":null}
   ```

## Compare the three searches

These ten queries ran against the deployed application's database on Oct 30, 2026. Each cell is the position of the first chunk from the page that answers the query; "No match" means full-text search returned nothing, and "Not in top 20" means the page wasn't among vector search's 20 results.

| Query | Page that answers it | Vector | Full-text | Hybrid |
| - | - | - | - | - |
| `BackoffLimitExceeded` | `applications/processes.md` | Not in top 20 | 1 | 1 |
| `fuzzystrmatch` | `databases/create.md` | Not in top 20 | 1 | 1 |
| `FLUSHALL` | `databases/studio.md` | 6 | 1 | 1 |
| `__cf_bm` | `applications/caching.md` | 3 | 1 | 1 |
| `ECONNREFUSED` | `applications/troubleshooting/runtime-errors.md` | 2 | 1 | 1 |
| `active_deadline_seconds` | `applications/processes.md` | 2 | 1 | 1 |
| my app ran out of memory and keeps restarting | `applications/troubleshooting/runtime-errors.md` | 1 | No match | 1 |
| how do I keep my database off the public internet | `databases/connect.md` | 1 | No match | 1 |
| undo a bad release | `applications/deployments.md` | 3 | No match | 3 |
| run a task every night at 3am | `applications/processes.md` | 1 | No match | 1 |
| **In the first 5 results** | | **7 of 10** | **6 of 10** | **10 of 10** |

* **Identifiers** (the first six queries) are where vector search is weakest: an embedding model has seen `fuzzystrmatch` or `BackoffLimitExceeded` rarely or never, so their vectors land near unrelated text. Full-text search matches the exact word.
* **Questions** (the last four) are where full-text search fails: `websearch_to_tsquery` requires every word of the query, after stop words, in the same chunk, and no chunk contains all of `app`, `ran`, `memory`, `keeps` and `restarting`. Vector search matches the meaning.
* In every query, hybrid search ranked the page at least as high as the better of the two searches.

To test your own documents the same way, collect queries your users send with the page that should answer each, and compare `semantic_rank` and `keyword_rank` in the responses.

## Measured latency and size

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

| What | Measured |
| - | - |
| A search, inside the application | 139 to 514 ms, median 188 ms, nearly all of it the embeddings request to OpenAI |
| The hybrid SQL query | 1.8 ms median, 3.6 ms at the 95th percentile |
| Each search alone | Vector search 1.5 ms, full-text search 0.4 ms (medians) |
| Query plan | Full-text search uses `chunks_tsv_idx` (`Bitmap Index Scan`). With a few hundred chunks, PostgreSQL reads the whole table for vector search instead of the HNSW index, because that's faster |
| Storage | 7 MB for the table and its indexes: 2.7 MB HNSW index, 1 MB GIN index |
| Web process memory | 77 MB, on H1's 300 MB |

The fusion adds little to the two searches: the hybrid query takes about as long as both together. The embeddings request dominates, so a hybrid search costs the same embeddings call as a vector search. For how the HNSW index behaves with tens of thousands of chunks, see [Scale the vector index](https://docs.sevalla.com/guides/ai/rag-pgvector.md#scale-the-vector-index).

## Tune the search

| Setting | Where | Effect |
| - | - | - |
| `CANDIDATES` | `app/main.py`, `20` | How many results each search contributes. More candidates let a chunk that both searches rank moderately well rise to the top, and cost a little more time |
| `RRF_K` | `app/main.py`, `60` | Lower values give the first results of each list more weight compared with the rest |
| Text search configuration | `'english'` in `schema.sql` and in the query | The language for stop words and stemming. Change both together, and recreate the column: drop `tsv` and run the migration |
| Query syntax | `websearch_to_tsquery` | Requires every word. Users can write `"exact phrase"`, `or` and `-word`. `plainto_tsquery` requires every word without the syntax |

To weight one search more than the other, multiply its term in `fused`, for example `2 * coalesce(1.0 / (%(rrf_k)s + keyword.rank), 0)` to favor exact matches in a catalog of part numbers.

## Add hybrid search to the RAG application

The application in [Build RAG with pgvector](https://docs.sevalla.com/guides/ai/rag-pgvector.md) has the same `documents` and `chunks` tables without the `tsv` column. Add these two statements to the end of its `app/schema.sql`:

```sql app/schema.sql theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
ALTER TABLE chunks ADD COLUMN IF NOT EXISTS
    tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;

CREATE INDEX IF NOT EXISTS chunks_tsv_idx ON chunks USING gin (tsv);
```

The next deployment's `Migrate` job adds the column, and PostgreSQL computes it for every chunk already stored, so nothing needs to be embedded again. On later runs, the `ALTER TABLE` statement changes nothing and prints `NOTICE:  column "tsv" of relation "chunks" already exists, skipping`. Then replace `SEARCH` in that application's `app/main.py` with this guide's query, passing the question as `q`, and select `content` and `score` in place of `distance`.

## 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. `COPY --from` takes its `uv` binary without a download script. |
| `uv sync --locked --no-dev` | Installs exactly what `uv.lock` pins. Because the lock files come before the code, a change to the code or the documents reuses the installed packages. |
| `useradd` and `USER 1000:1000` | Runs the application as an unprivileged user, the same user ID as Ingot's images. The code stays owned by root, so `compileall` compiles it at build time. |
| `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 and shuts down cleanly on `SIGTERM`. |

```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 key 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 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 ingestion skips every document because none changed. The domain still returns `{"chunks":341}`, and the searches answer as before. In the test, a later deployment that changed only `README.md` reused the `uv sync` layer (`CACHED` in the build log). 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), then trigger and wait for a deployment as in that section:

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

CLI: `sevalla apps update $APP_ID --data '{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}'`. 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`, which embeds only the files that changed. 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 `search.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 `Migrate` job fails with `permission denied to create extension "vector"` and `HINT:  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 the application to it, remove the connection to the old one, and deploy. The `Ingest` job fills the new database. |
| `/search` returns `401` with `{"detail":"Not authenticated"}` or `{"detail":"Invalid token"}` | The request has no `Authorization` header, or its token isn't the value of `SEARCH_TOKEN`. | Send `Authorization: Bearer <token>` with the value on the application's **Environment variables** tab. |
| `/search` returns `422` with `String should have at least 1 character` | `q` is empty or missing. | Send the query in `q`, 1 to 500 characters. |
| Every result has `"keyword_rank":null` | No chunk contains every word of the query after stemming, or the query has only stop words such as `how to`. | Expected for questions. To match chunks with some of the words, write `or` between them, or rely on vector search, which still ranks the results. |
| [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. | 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. |

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`.
* `OPENAI_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/docsearch
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`. Pick one by region ID, here Frankfurt, Germany:

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

```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`, the API's name for **Enable pgvector extension**. You choose the password, and the API never returns it, so keep it in a variable. Then 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"}}
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: "docsearch-db", type: "postgresql", version: $version, cluster_id: $cluster,
      resource_type_id: $size, db_name: "docsearch", db_user: "docsearch", db_password: $password,
      extensions: {enable_vector: true}}')" \
  | jq -r .id)
for i in $(seq 1 60); do
  STATUS=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r '.status // empty')
  [ "$STATUS" = ready ] && break
  sleep 10
done
echo "$STATUS"
```

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

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.

**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: "docsearch", cluster_id: $cluster, source: "privateGit", git_type: "github",
      repo_url: $repo, default_branch: $branch}')" \
  | jq -r .id)
```

CLI: `sevalla apps create --name docsearch --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. 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 '.readiness_probe.httpGet'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"path":"/","port":8080,"scheme":"HTTP"}
```

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`). Unlike the dashboard, the API doesn't add the connection's environment variables; the next step adds them:

```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 'has("id")'
```

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

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, then the API key and a new `SEARCH_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)
SEARCH_TOKEN=$(openssl rand -hex 32)
for pair in "DB_HOST=$DB_HOST" "DB_PORT=$DB_PORT" "DB_DATABASE=docsearch" "DB_USERNAME=docsearch" \
  "DB_PASSWORD=$DB_PASSWORD" "DB_URL=postgres://docsearch:$DB_PASSWORD@$DB_HOST:$DB_PORT/docsearch" \
  "OPENAI_API_KEY=$OPENAI_API_KEY" "SEARCH_TOKEN=$SEARCH_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. `SEARCH_TOKEN` stays in your shell for the checks.

CLI: `sevalla apps env-vars create $APP_ID --key SEARCH_TOKEN --value $SEARCH_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
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"display_name":"Migrate","job_start_policy":"beforeDeployment"}
{"display_name":"Ingest","job_start_policy":"afterSuccessDeployment"}
```

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

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until the status is final. It reaches `success` after the `Ingest` job, about 3 minutes after the trigger in the test:

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

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

On `failed`, read the whole build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`); without `limit`, it returns the latest 1,000 lines:

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

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. 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"}}
for i in $(seq 1 30); do
  APP_HOST=$(curl -s "$API/applications/$APP_ID/domains" -H "$H" | jq -r '.data[] | select(.is_primary) | .name')
  [ -n "$APP_HOST" ] && break
  sleep 2
done
curl -s "https://$APP_HOST/"
```

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

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

Then run 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.