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

# Migrate from Vercel

> Move a Next.js project from Vercel to Sevalla: how functions, ISR, middleware, Cron Jobs, Blob, Neon and preview deployments map, which code changes, and how to copy the data and domains.

A Next.js project on Vercel moves to Sevalla as an application whose web process runs `next start`, a PostgreSQL database in place of a Marketplace Postgres database such as Neon, an object storage bucket in place of Vercel Blob, and a cron job for each entry in `vercel.json`'s `crons`. You change a few files, recreate the setup while Vercel keeps serving, copy the data during a short maintenance window, then point the domains at Sevalla.

## At a glance

| | |
| - | - |
| Source | The project's Git repository, on GitHub, GitLab or Bitbucket |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Node.js 24, npm, `next build`), as in [Deploy a Next.js application](https://docs.sevalla.com/guides/javascript/nextjs.md) |
| Start command | `next start`, from `scripts.start` in `package.json` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; one **Cron job** per entry in `vercel.json`'s `crons`, running `node scripts/cron.mjs <path>` |
| Services | A PostgreSQL database for Neon or another Marketplace Postgres database, over an internal connection; a bucket with public access for Vercel Blob; a Redis database for the [shared Next.js cache](#share-the-next-js-cache-between-instances) when the web process runs more than one instance |
| Disk | None |
| Size | **S1** or larger for the web process if you move a custom domain, because **H1** can't have one |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DATABASE_URL` | With Postgres | Build and runtime | Yes | `DB_URL` from the internal connection to `storefront-db`, renamed: `postgres://<user>:<password>@<host>:<port>/<database>`. `next build` prerenders ISR pages, so the build needs it too |
| `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | With Vercel Blob | Build and runtime | The keys | The bucket's **Endpoint**, **Bucket name**, **Access key** and **Secret key** |
| `S3_PUBLIC_URL` | With Vercel Blob | Build and runtime | No | The bucket's public domain: `https://storefront-files-x7k2m.sevalla.storage` |
| `CRON_SECRET` | With Cron Jobs | Build and runtime | Yes | The value from Vercel, or a new one: `openssl rand -hex 24` |
| `APP_URL` | With Cron Jobs | Build and runtime | No | The web process's internal address: `http://<application name>-web.<application name>.svc.cluster.local:8080` |
| `REDIS_URL` | With more than one instance | Build and runtime | Yes | Added by the internal connection to `storefront-cache`: `redis://:<password>@<host>:<port>/0` |
| The project's own variables | As on Vercel | Build and runtime | As on Vercel | From `vercel env pull --environment=production`, without the ones Vercel and its integrations set |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port. Don't add it |

## How Vercel maps to Sevalla

| Vercel | Sevalla | Notes |
| - | - | - |
| Project | [Application](https://docs.sevalla.com/applications/overview.md) | Built from a Git repository by [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), which runs `next build` and starts `next start` |
| Production deployment | [Deployment](https://docs.sevalla.com/applications/deployments.md) of the application's branch | Each one builds an image and replaces the instances with no downtime |
| Vercel Functions, Fluid compute | The web process | Route handlers, Server Components and Server Actions run in the long-running `next start` server of each instance |
| Function region (`iad1` by default) | [Location](https://docs.sevalla.com/platform/locations.md) | Pick the one next to your users and your database. `iad1` is next to **Ashburn, USA** |
| Routing Middleware (`proxy.ts`, `middleware.ts`) | Runs in `next start` | In your location, not at Vercel's edge. See [Proxy and middleware](#proxy-and-middleware) |
| Edge runtime (`export const runtime = "edge"`) | Runs in `next start` | No separate Edge Functions. See [Edge runtime routes](#edge-runtime-routes) |
| `geolocation()` and `ipAddress()` from `@vercel/functions` | The `CF-IPCountry` and `CF-Connecting-IP` request headers | See [Request headers](https://docs.sevalla.com/applications/http-headers.md) |
| Incremental Static Regeneration (ISR) | The Next.js cache of each instance | One instance works as is. More need a [shared cache](#share-the-next-js-cache-between-instances) |
| Image Optimization (`next/image`) | `next start` optimizes images itself | Allow the bucket's domain in `images.remotePatterns` |
| CDN caching of static files | [CDN and edge caching](https://docs.sevalla.com/applications/caching.md) | Turned on per application |
| `vercel.json` `redirects`, `rewrites`, `headers` | `redirects()`, `rewrites()`, `headers()` in `next.config.ts` | Same fields: `source`, `destination`, `permanent`, `headers` |
| `vercel.json` `crons`, Vercel Cron Jobs | [Cron job](https://docs.sevalla.com/applications/processes.md#cron-jobs) | Any schedule, in UTC unless you set a time zone. A script calls the route with `CRON_SECRET` |
| Production environment variables | [Environment variables](https://docs.sevalla.com/applications/environment-variables.md) | Available during the build, at runtime or both |
| Preview environment variables | The preview app settings of a [pipeline](https://docs.sevalla.com/pipelines/preview-apps.md#environment-variables) | |
| Development environment variables | Your local `.env.local` | Sevalla has no development environment |
| Preview deployments | [Preview apps](https://docs.sevalla.com/pipelines/preview-apps.md) | One application per pull request or branch, in a pipeline |
| Neon, Supabase, Prisma Postgres (Marketplace) | [PostgreSQL database](https://docs.sevalla.com/databases/overview.md) | |
| Upstash for Redis (Marketplace) | Redis or Valkey database | |
| Vercel Blob | [Object storage](https://docs.sevalla.com/object-storage/overview.md) | An S3-compatible bucket; public access gives it a `sevalla.storage` domain |
| Instant Rollback | [Roll back a deployment](https://docs.sevalla.com/applications/deployments.md#roll-back-a-deployment) | |
| `vercel logs` | [Logs](https://docs.sevalla.com/applications/logs.md) | |
| Domains | [Custom domains](https://docs.sevalla.com/domains/overview.md) | Sevalla issues and renews the SSL certificates |
| Vercel DNS | No equivalent | Sevalla doesn't host DNS. Keep the domain's DNS at Vercel or move it |
| Deployment Protection | No equivalent | Preview apps are public. Use [IP restrictions](https://docs.sevalla.com/applications/networking.md#ip-restrictions) or your own sign-in |
| Edge Config | No equivalent | Use environment variables or the database |
| Neon database branches for preview deployments | No equivalent | Point preview apps at a separate database |
| Teams | [Users and roles](https://docs.sevalla.com/company/users-and-roles.md) | |
| Vercel CLI and REST API | [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) and [CLI](https://docs.sevalla.com/developer-tools/cli.md) | |

## Before you begin

* The Vercel CLI, signed in, and the project linked in a checkout of its repository (`vercel link`).
* The code in a repository on GitHub, GitLab or Bitbucket. Sevalla doesn't deploy from a folder the way `vercel deploy` does. See [Git providers](https://docs.sevalla.com/git/overview.md).
* A Sevalla company with a payment method.
* The PostgreSQL client tools, at the same major version as the source database or newer: Neon runs PostgreSQL 18 today. See [Import and export PostgreSQL](https://docs.sevalla.com/databases/import-export/postgresql.md#before-you-begin).
* Node.js 20 or later, for the script that copies the Blob files.
* A maintenance window: the Vercel project is paused while you copy the data.

## Take stock of the Vercel project

Collect what you'll recreate, from the root of the repository:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
vercel project inspect
vercel integration list
vercel blob list-stores
vercel crons list
vercel domains ls
vercel env pull vercel.env --environment=production
grep -rlE "@vercel/(blob|functions|kv|postgres|edge-config)|@neondatabase/serverless" \
  --include="*.[jt]s" --include="*.[jt]sx" --include="*.mjs" --exclude-dir=node_modules --exclude-dir=.next .
```

* `integration list` shows the Marketplace resources, such as a Neon database, and `blob list-stores` the Blob stores with their file counts.
* `crons list` and `vercel.json` show the cron jobs, redirects, rewrites and headers to recreate.
* `vercel.env` holds the production values. Secret values can't be read back, so the file has `"[SENSITIVE]"` in their place, and the CLI says how many: ``! 2 Secret values cannot be pulled from the `production` Environment.`` Get those values from where you keep them, or create new ones. The file contains secrets: don't commit it.
* The `grep` lists the files that use a Vercel SDK or the Neon serverless driver: the next section changes them.
* Vercel runs functions in `iad1` (Washington, D.C., USA) unless you changed the project's function region. Choose the Sevalla location closest to it, such as **Ashburn, USA**. See [Locations](https://docs.sevalla.com/platform/locations.md).
* Check the database version with `psql "$(sed -n 's/^DATABASE_URL_UNPOOLED=//p' vercel.env | tr -d '"')" -Atc 'show server_version'`. Create the Sevalla database with the same version or a newer one.

## Prepare the code for Sevalla

Make these changes on a new branch, here `sevalla`, so that Vercel keeps deploying your production branch until the switch. Each one replaces something only Vercel provides.

### Switch the database driver

The Neon serverless driver (`@neondatabase/serverless`) sends queries over HTTP to Neon's proxy, which a Sevalla PostgreSQL database doesn't have. [Postgres.js](https://github.com/porsager/postgres) has the same tagged-template API, so only the import changes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm uninstall @neondatabase/serverless
npm install postgres
```

```diff lib/db.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
-import { neon } from "@neondatabase/serverless";
+import postgres from "postgres";

-export const sql = neon(process.env.DATABASE_URL!);
+export const sql = postgres(process.env.DATABASE_URL!);
```

Queries such as `` sql`SELECT id, name FROM products ORDER BY id` `` return the same rows. Code that already uses a standard PostgreSQL driver, such as `pg`, needs only the new `DATABASE_URL`.

### Move uploads from Vercel Blob to the S3 API

Replace `put()` from `@vercel/blob` with the AWS SDK's `PutObjectCommand`, and build the file's URL from the bucket's public domain:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm uninstall @vercel/blob
npm install @aws-sdk/client-s3
```

```diff app/api/upload/route.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
-import { put } from "@vercel/blob";
+import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
 import { sql } from "@/lib/db";

+const s3 = new S3Client({
+  region: "auto",
+  endpoint: process.env.S3_ENDPOINT,
+  credentials: {
+    accessKeyId: process.env.S3_ACCESS_KEY_ID!,
+    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
+  },
+  requestChecksumCalculation: "WHEN_REQUIRED",
+  responseChecksumValidation: "WHEN_REQUIRED",
+});
+
 export async function POST(request: Request) {
   const token = process.env.ADMIN_TOKEN;
   if (!token || request.headers.get("authorization") !== `Bearer ${token}`) {
     return new Response("Unauthorized", { status: 401 });
   }
   const file = (await request.formData()).get("file") as File;
-  const blob = await put(`products/${file.name}`, file, {
-    access: "public",
-    addRandomSuffix: true,
-  });
-  await sql`INSERT INTO uploads (pathname, url, size) VALUES (${blob.pathname}, ${blob.url}, ${file.size})`;
-  return Response.json({ pathname: blob.pathname, url: blob.url });
+  const pathname = `products/${crypto.randomUUID()}-${file.name}`;
+  await s3.send(
+    new PutObjectCommand({
+      Bucket: process.env.S3_BUCKET,
+      Key: pathname,
+      Body: new Uint8Array(await file.arrayBuffer()),
+      ContentType: file.type,
+    }),
+  );
+  const url = `${process.env.S3_PUBLIC_URL}/${pathname}`;
+  await sql`INSERT INTO uploads (pathname, url, size) VALUES (${pathname}, ${url}, ${file.size})`;
+  return Response.json({ pathname, url });
 }
```

`crypto.randomUUID()` keeps names unique, as `addRandomSuffix` did. For the other Blob calls, `list()` becomes `ListObjectsV2Command`, `del()` `DeleteObjectCommand`, and a private file's `get()` a [presigned URL](https://docs.sevalla.com/object-storage/connect.md#share-files-with-presigned-urls). See [Use object storage from Node.js](https://docs.sevalla.com/guides/javascript/object-storage.md).

### Read the country from Cloudflare headers

`geolocation()` and `ipAddress()` read headers that only Vercel sets. Every request to Sevalla passes through Cloudflare, which sets `CF-IPCountry` and `CF-Connecting-IP`:

```diff proxy.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
 import { NextResponse, type NextRequest } from "next/server";
-import { geolocation } from "@vercel/functions";

 export function proxy(request: NextRequest) {
   if (request.nextUrl.pathname.startsWith("/account") && !request.cookies.has("session")) {
     return NextResponse.redirect(new URL("/", request.url));
   }
   const response = NextResponse.next();
-  response.headers.set("x-visitor-country", geolocation(request).country ?? "unknown");
+  response.headers.set("x-visitor-country", request.headers.get("cf-ipcountry") ?? "unknown");
   return response;
 }
```

Then `npm uninstall @vercel/functions`. Cloudflare doesn't send a city or a region, only the country. See [Request headers](https://docs.sevalla.com/applications/http-headers.md).

### Move the vercel.json rules into next.config.ts

Sevalla doesn't read `vercel.json`. Its `redirects`, `rewrites` and `headers` have the same fields in `next.config.ts`, so copy each list into a function and delete `vercel.json`. Add the bucket's domain to `images.remotePatterns` in place of Vercel Blob's:

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

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [{ protocol: "https", hostname: "*.sevalla.storage" }],
  },
  async redirects() {
    return [{ source: "/shop", destination: "/products", permanent: true }];
  },
  async rewrites() {
    return [{ source: "/catalog.json", destination: "/api/products" }];
  },
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [{ key: "X-Frame-Options", value: "DENY" }],
      },
    ];
  },
};

export default nextConfig;
```

`permanent: true` answers `308`, as on Vercel. A redirect from one domain to another, which Vercel sets in the project's domain settings, becomes a redirect with a [`has` host condition](https://nextjs.org/docs/app/api-reference/config/next-config-js/redirects#header-cookie-and-query-matching).

### Add a script for the cron jobs

Vercel Cron Jobs send a `GET` request to a path with `Authorization: Bearer <CRON_SECRET>`. A Sevalla cron job runs a command in a new instance of the application, so this script sends the same request to the web process:

```js scripts/cron.mjs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
// Calls a route the way Vercel Cron Jobs do: GET with the CRON_SECRET as a bearer token.
const response = await fetch(new URL(process.argv[2], process.env.APP_URL), {
  headers: { authorization: `Bearer ${process.env.CRON_SECRET}` },
});
console.log(response.status, await response.text());
if (!response.ok) process.exit(1);
```

The route keeps its `CRON_SECRET` check. Vercel also sends the `x-vercel-cron-schedule` header; if your route reads it, pass the schedule another way.

Commit the changes, `package-lock.json` included, and push the branch:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
git checkout -b sevalla
git add -A
git commit -m "Move from Vercel to Sevalla"
git push -u origin sevalla
```

## Create the services

**1. Create the PostgreSQL database**

In the sidebar, click **Databases**, then click **Create database**. Under **Database**, choose **PostgreSQL** and a version at least as new as the source's. Enter `storefront-db` as the **Display name**, choose the **Data center** the application will use and a **Size** with room for the data, then click **Create database**. See [Create a database](https://docs.sevalla.com/databases/create.md).

**2. Create the bucket**

If the project uses Vercel Blob: in the sidebar, click **Object storage**, then click **Create object storage**. Enter `storefront-files` as the **Name**, choose the **Location** closest to the application, and click **Create object storage**.

**3. Make the bucket public**

For a public Blob store: on the bucket's **Settings** tab, in **Public access**, click **Enable public access**, then click **Enable public access** in the dialog. **Public domain** shows the domain, such as `storefront-files-x7k2m.sevalla.storage`. Keep files from a private Blob store in a private bucket. See [Public access](https://docs.sevalla.com/object-storage/settings.md#public-access).

**4. 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 `sevalla` **Branch**. Under **Location**, choose the same **Data center** as the database, choose **S1** or a larger **Size** if you'll move a custom domain, and click **Create application**. The application opens with the status **Not deployed yet**: don't deploy it before the data is copied, because `next build` prerenders the ISR pages from the database.

## Connect the database

On the application's **Networking** tab, under **Connected services**, click **Add internal connection**. Choose `storefront-db` as the **Service** and turn on **Add environment variables to the application**. Keep **Available during runtime** and **Available during buildtime** checked, change the key `DB_URL` to `DATABASE_URL`, and click **Add internal connection**. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

The URL starts with `postgres://` and has no `sslmode`: the connection stays inside the data center. Code that forces TLS for Neon, such as `ssl: "require"` in the driver options, must drop it.

## Move the environment variables

**1. Clean up the exported file**

Drop the variables that Vercel and its integrations set, and the quotes that `vercel env pull` adds, which the dashboard would keep as part of the value:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
grep -vE '^(#|VERCEL|TURBO_|NX_DAEMON|BLOB_|DATABASE_URL|PG|POSTGRES_|NEON_|VITE_NEON_)' vercel.env \
  | sed -E 's/^([^=]+)="(.*)"$/\1=\2/' > sevalla.env
```

In `sevalla.env`, replace each `[SENSITIVE]` with the real value, and any value that contains the Vercel domain with the application's `sevalla.app` domain. Code that reads `VERCEL_URL` or `VERCEL_ENV` needs a variable of your own instead. Then add the bucket's values and the two that the cron script reads:

```bash sevalla.env theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
S3_BUCKET=storefront-files-x7k2m
S3_ACCESS_KEY_ID='<access key>'
S3_SECRET_ACCESS_KEY='<secret key>'
S3_PUBLIC_URL=https://storefront-files-x7k2m.sevalla.storage
CRON_SECRET='<the value from Vercel, or openssl rand -hex 24>'
APP_URL=http://storefront-x7k2m-web.storefront-x7k2m.svc.cluster.local:8080
```

The bucket's values are on its **Settings** tab, under **Details** and **Access keys**. `APP_URL` is the web process's internal hostname, shown on the application's **Networking** tab under **Internal hostnames**, with the web process port: the cron job calls the web process without leaving the data center.

**2. Import the file**

Click the **Environment variables** tab, then click **Add environment variables**. Click **Import .env** and choose `sevalla.env`. Keep **Available during runtime** and **Available during buildtime** selected, and click **Add environment variables**.

Don't add `PORT`: Sevalla sets it. Variables of the **Preview** environment belong to the preview apps, in [Set up preview deployments](#set-up-preview-deployments). **Development** values stay in your local `.env.local`.

## Recreate the cron jobs

For each entry in `vercel.json`'s `crons`, click **Create process** on the application's **Processes** tab. Choose **Cron job**, enter a **Name**, enter `node scripts/cron.mjs` followed by the entry's `path` as the **Custom start command**, and copy its `schedule` into **Schedule**. Both platforms read the schedule in UTC. Choose the smallest **Resource**, **H1**, and click **Create process**.

| `vercel.json` | Sevalla cron job |
| - | - |
| `{ "path": "/api/cron", "schedule": "0 5 * * *" }` | **Custom start command** `node scripts/cron.mjs /api/cron`, **Schedule** `0 5 * * *` |

Vercel's Hobby plan runs a cron job once a day at most, at any minute within the hour. A Sevalla cron job runs at the minute you set, as often as every minute. See [Cron jobs](https://docs.sevalla.com/applications/processes.md#cron-jobs).

## Copy the PostgreSQL data

**1. Stop writes on Vercel**

Pause the project, which answers every production request with `503` and `DEPLOYMENT_PAUSED`, and stops the cron jobs from writing:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
vercel project pause storefront
```

The CLI asks you to type the project name. Without a terminal, use the REST API: `curl -X POST "https://api.vercel.com/v1/projects/<project-id>/pause" -H "Authorization: Bearer $VERCEL_TOKEN"`.

**2. Dump the database**

Use the unpooled connection, `DATABASE_URL_UNPOOLED`, which Neon recommends for `pg_dump`, and leave out schemas that belong to Neon, such as `neon_auth`, which Neon Auth creates:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pg_dump "$(sed -n 's/^DATABASE_URL_UNPOOLED=//p' vercel.env | tr -d '"')" \
  --format=custom --no-owner --no-privileges --exclude-schema=neon_auth --file=vercel.dump
```

**3. Open the Sevalla database to your computer**

In the sidebar, click **Databases**, then click `storefront-db`. Click the **Networking** tab, click **Enable** under **External connection**, then click **Enable** in the dialog. Copy the **External hostname**, **External port** and password. See [Enable the external connection](https://docs.sevalla.com/databases/connect.md#enable-the-external-connection).

**4. Restore the dump**

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pg_restore --host=storefront-db-k3j4h.ext.sevalla.app --port=20145 \
  --username=storefront --dbname=storefront \
  --no-owner --no-privileges --jobs=4 vercel.dump
```

It prints nothing when every object restores.

**5. Compare the tables**

Save a query that counts each table's rows and hashes them, then run it on both databases. Name your own tables in it:

```sql compare.sql theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SELECT 'products', count(*), md5(string_agg(t::text, ',' ORDER BY id)) FROM products t
UNION ALL SELECT 'orders', count(*), md5(string_agg(t::text, ',' ORDER BY id)) FROM orders t
UNION ALL SELECT 'uploads', count(*), md5(string_agg(t::text, ',' ORDER BY id)) FROM uploads t;
```

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
PGTZ=UTC psql "$(sed -n 's/^DATABASE_URL_UNPOOLED=//p' vercel.env | tr -d '"')" -At -f compare.sql > vercel.sums
PGTZ=UTC psql --host=storefront-db-k3j4h.ext.sevalla.app --port=20145 --username=storefront --dbname=storefront -At -f compare.sql > sevalla.sums
diff vercel.sums sevalla.sums && echo "Tables match"
```

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

To rehearse, run these steps without pausing the project, deploy, and test on the `sevalla.app` domain. For the real cutover, add `--clean --if-exists` to `pg_restore`, so it replaces the rehearsal's tables.

## Copy the Vercel Blob files

**1. Copy the files**

Save this script in an empty folder, install its two packages, and run it with the Blob token from `vercel.env` and the bucket's values. It copies every blob to the same path in the bucket, with its content type:

```js copy-blob.mjs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { get, list } from "@vercel/blob";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";

const s3 = new S3Client({
  region: "auto",
  endpoint: process.env.S3_ENDPOINT,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
  },
  requestChecksumCalculation: "WHEN_REQUIRED",
  responseChecksumValidation: "WHEN_REQUIRED",
});

let copied = 0;
let cursor;
do {
  const page = await list({ cursor });
  for (const blob of page.blobs) {
    // Use access: "private" for a private Blob store.
    const file = await get(blob.url, { access: "public" });
    await s3.send(
      new PutObjectCommand({
        Bucket: process.env.S3_BUCKET,
        Key: blob.pathname,
        Body: new Uint8Array(await new Response(file.stream).arrayBuffer()),
        ContentType: file.blob.contentType,
      }),
    );
    copied++;
  }
  cursor = page.cursor;
} while (cursor);
console.log(`Copied ${copied} files`);
```

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm install @vercel/blob @aws-sdk/client-s3
BLOB_READ_WRITE_TOKEN="$(sed -n 's/^BLOB_READ_WRITE_TOKEN=//p' vercel.env | tr -d '"')" \
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com S3_BUCKET=storefront-files-x7k2m \
S3_ACCESS_KEY_ID=<access key> S3_SECRET_ACCESS_KEY=<secret key> \
node copy-blob.mjs
```

It prints the number of files, such as `Copied 6 files`, which matches the **Files** column of `vercel blob list-stores`. The bucket's **Objects** tab lists them.

**2. Point the stored URLs at the bucket**

The database holds the full URLs that `put()` returned, on the store's `public.blob.vercel-storage.com` domain. Replace that domain with the bucket's in every column that stores one:

```sql theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
UPDATE products SET image_url = replace(image_url, 'https://k3j4h5m6n7p8q9r0.public.blob.vercel-storage.com', 'https://storefront-files-x7k2m.sevalla.storage');
UPDATE uploads SET url = replace(url, 'https://k3j4h5m6n7p8q9r0.public.blob.vercel-storage.com', 'https://storefront-files-x7k2m.sevalla.storage');
```

The path after the domain stays the same, because the script kept each blob's path.

## Deploy and verify

**1. Deploy**

On the application's **Overview** tab, click **Deploy now** under **Recent deployments**, check the **Branch**, and click **Deploy**.

**2. Check the deployment**

On the **Deployments** tab, wait for the status **Deployed**. The build log shows the routes that `next build` prerendered, such as `└ ○ /products                 1m      1y` for an ISR page, then `🚀 Web has been deployed successfully.` and `🚀 Cron /api/cron has been deployed successfully.`

**3. Compare the responses**

Request the same paths from Vercel, during a rehearsal, and from the application's domain. They answer with the same status codes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for path in / /products /shop /account /catalog.json /api/products /api/cron; do
  echo "$path $(curl -s -o /dev/null -w '%{http_code}' https://storefront.vercel.app$path) $(curl -s -o /dev/null -w '%{http_code}' https://storefront-x7k2m.sevalla.app$path)"
done
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
/ 200 200
/products 200 200
/shop 308 308
/account 307 307
/catalog.json 200 200
/api/products 200 200
/api/cron 401 401
```

API responses match too, except for file URLs, which now point at the bucket. The `x-visitor-country` header that `proxy.ts` sets shows your country.

**4. Run a cron job**

On the **Processes** tab, click **⋯** on the cron job, click **Run now**, then click **Run now** in the dialog. The run shows **Succeeded**, and the **Logs** tab has the line the script prints, such as `200 {"ok":true,"run":{"id":2,"ran_at":"2026-10-30T11:14:50.535Z"}}`. A `401` means `CRON_SECRET` differs from the value the route checks.

**5. Turn on auto-deploy**

To deploy every push to the branch, click the **Settings** tab, then click **Update auto-deploy** in the **Auto-deploy** section. Turn on **Auto-deploy**, choose the **Branch**, and click **Update auto-deploy**. See [Deployments](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push).

## How Next.js features run on Sevalla

`next start` supports every Next.js feature, so the application runs as on Vercel. What changes is where some of the code runs.

### Proxy and middleware

`proxy.ts` (`middleware.ts` before Next.js 16) runs in the `next start` server of each instance, in the application's location, before the request reaches the page. On Vercel it runs in the region closest to the visitor. In a test from Central Europe, a redirect from `proxy.ts` took 97 ms on Vercel and 191 ms from an application in Ashburn, USA, after the TLS handshake. A route handler that queries the database took about 205 ms on both, because Vercel ran it in `iad1` too. For visitors far from your location, cache public pages with [edge caching](https://docs.sevalla.com/applications/caching.md).

### Edge runtime routes

Sevalla has no Edge Functions. A route with `export const runtime = "edge"` still works: `next start` runs it in the same server, in an Edge runtime sandbox, and `next build` warns `The Edge Runtime is deprecated. You can use the "nodejs" runtime instead.` Remove the line to run the route on Node.js.

### Image optimization

`next/image` works without configuration: `next start` resizes images and caches the results on the instance. Each remote host needs an entry in `images.remotePatterns`; without one, the image request answers `400` with `"url" parameter is not allowed`.

### ISR with more than one instance

With one instance, ISR works as on Vercel. Each instance keeps its own Next.js cache, though, so with two or more instances, a page that one instance regenerates stays old on the others. `revalidatePath()` and `revalidateTag()` only reach the instance that handles the call.

In a test with two instances and `export const revalidate = 60`, a price changed in the database, and a call to a route that runs `revalidatePath("/products")` reached one instance. Of the next 20 requests, 11 showed the new price and 9 the old one, from the other instance. That instance showed the new price only after its own `revalidate` period ran out, 38 seconds later. A page that relies only on on-demand revalidation would stay old there until the next deployment.

Keep the web process at one instance, or share the cache as in [Share the Next.js cache between instances](#share-the-next-js-cache-between-instances).

## Share the Next.js cache between instances

A cache handler stores the Next.js cache in Redis, where every instance reads and revalidates the same entries. These steps use the [Redis cache handler from the Next.js examples](https://github.com/vercel/next.js/tree/canary/examples/cache-handler-redis).

**1. Create the Redis database**

Create a database as in [Create the services](#create-the-services): choose **Redis**, enter `storefront-cache` as the **Display name**, and choose the application's **Data center**.

**2. Connect it**

On the application's **Networking** tab, add an internal connection to `storefront-cache` with **Add environment variables to the application** on. Its URL is already called `REDIS_URL`.

**3. Add the cache handler**

From the root of the repository:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -o cache-handler.js https://raw.githubusercontent.com/vercel/next.js/canary/examples/cache-handler-redis/cache-handler.js
npm install redis
```

Then point Next.js at it and turn off the in-memory cache, so that Redis is the only copy:

```diff next.config.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
 const nextConfig: NextConfig = {
+  cacheHandler: require.resolve("./cache-handler.js"),
+  cacheMaxMemorySize: 0,
   images: {
```

Commit and push the changes.

**4. Add instances**

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

In the same test with the handler and two instances, all 20 requests after the `revalidatePath()` call showed the new price. The handler connects only at runtime, so `next build` doesn't need Redis. If Redis is down, pages render without the cache and revalidation calls fail with `Redis is unavailable, so the tag revalidation was not recorded`. The handler covers ISR pages and route handlers; for `'use cache'` with Cache Components, the same example has a `cacheHandlers` entry.

## Set up preview deployments

Vercel builds a preview deployment for every branch and pull request. On Sevalla, put the application in a [pipeline](https://docs.sevalla.com/pipelines/overview.md) and turn on [preview apps](https://docs.sevalla.com/pipelines/preview-apps.md): each pull request gets its own application, deployed on every push and deleted when the pull request closes.

1. In the sidebar, click **Applications**, click **Create**, then click **Pipeline**. Keep **Trunk-based development** and click **Create pipeline**. In the **Production** stage, click **Add app**, pick **Existing app** and `storefront`, and click **Add app to stage**. See [Create a pipeline](https://docs.sevalla.com/pipelines/overview.md#create-a-pipeline).
2. Turn on preview apps as in [Enable preview apps](https://docs.sevalla.com/pipelines/preview-apps.md#enable-preview-apps), with the same repository, location and **Ingot** build type, and a **Cron** process if previews need one.
3. Add the **Preview** environment's variables in the settings' **Environment variables** section. Get them with `vercel env pull vercel-preview.env --environment=preview` and clean them up as in [Move the environment variables](#move-the-environment-variables).

Preview apps don't get the production application's internal connections, so give them a database of their own and set its connection URL as `DATABASE_URL` in the preview settings, or add an internal connection on each preview app's **Networking** tab. Preview apps are public on their `sevalla.app` domain, where Vercel's Deployment Protection asked for a sign-in.

## Move the custom domains

**1. Add each domain**

On the application's **Domains** tab, click **Add custom domain**, enter the domain in **Domain name**, for example `shop.example.com`, and click **Add custom domain**.

**2. Verify it**

Add the `TXT` records that Sevalla shows (`_cf-custom-hostname` and `_acme-challenge`) at your DNS provider. Vercel keeps serving the domain meanwhile. If the domain uses Vercel's nameservers, add them with the Vercel CLI, for example:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
vercel dns add example.com _cf-custom-hostname.shop TXT <value shown>
vercel dns add example.com @ CAA '0 issue "pki.goog"'
```

Vercel DNS adds a `CAA` record for Let's Encrypt to the domain, and Sevalla's certificates come from Google Trust Services, so the second record lets them be issued. When the domain's status changes to **Pointing**, its certificate is ready. See [Verify the domain](https://docs.sevalla.com/domains/overview.md#verify-the-domain).

**3. Point it at Sevalla**

Replace the record that points at Vercel (an `A` record to `76.76.21.21` or a `CNAME` to a `vercel-dns` target) with the `A` record that Sevalla shows. On Vercel DNS, add it with `vercel dns add example.com shop A <address shown>`: a record you add overrides the one Vercel creates for the project. The status changes to **Active** when Sevalla sees it. See [Point the domain](https://docs.sevalla.com/domains/overview.md#point-the-domain).

To keep the switch without downtime, verify the domain before the maintenance window, and point it once the data is copied and the deployment checks pass.

## Shut down the Vercel project

When the domains are **Active** and traffic reaches Sevalla:

1. Disable the external connection: on the database's **Networking** tab, click **Disable** under **External connection**, then click **Disable** in the dialog.
2. Merge the `sevalla` branch into your production branch, and choose that branch in the application's **Auto-deploy** settings.
3. Remove the domain from the Vercel project's **Domains** settings. Keep it in your Vercel account while its DNS is on Vercel's nameservers: removing it from the account with `vercel domains rm` removes its DNS records too.
4. Keep `vercel.dump` until you no longer need it. Then delete the Vercel project with `vercel project delete storefront`, empty the Blob store with `vercel blob empty-store <store-id>` and delete it with `vercel blob delete-store <store-id>` (a store that still has files can't be deleted: `Blob store not empty`), delete the Neon database with `vercel integration resource delete <name>`, and remove the integration with `vercel integration remove neon`. Deleting the store or the database deletes their data.

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| `pg_restore: error: could not execute query: ERROR:  schema "public" already exists` | The dump was made with `--schema=public`, which includes `CREATE SCHEMA public` | Ignore it: the tables restore. Or dump with `--exclude-schema` for the schemas you leave out, as in [Copy the PostgreSQL data](#copy-the-postgresql-data) |
| The build fails with `Error occurred prerendering page "/products"` and `relation "products" does not exist` | `next build` prerenders ISR pages from the database, and the data isn't restored yet | Restore the data, then deploy again. If the error is a connection error instead, keep **Available during buildtime** selected for `DATABASE_URL` |
| Values arrive with quotes, such as `"Storefront"` | `vercel env pull` writes quoted values, and **Import .env** keeps the quotes | Remove them as in [Move the environment variables](#move-the-environment-variables) |
| `vercel project pause` prints `This cannot be confirmed non-interactively` | The CLI needs a terminal to confirm | Pause with the REST API, as in [Copy the PostgreSQL data](#copy-the-postgresql-data) |
| The cron job's log shows `401 Unauthorized` | `CRON_SECRET` on Sevalla differs from what the route compares, or is still `[SENSITIVE]` | Set the same value, then deploy |
| Images answer `400` with `"url" parameter is not allowed` | The bucket's domain isn't in `images.remotePatterns` | Add `*.sevalla.storage`, as in [Move the vercel.json rules into next.config.ts](#move-the-vercel-json-rules-into-next-config-ts) |
| Instances show different versions of a page | Each instance has its own Next.js cache | [Share the cache](#share-the-next-js-cache-between-instances), or run one instance |

## Deploy with the API

This sequence does what [Create the services](#create-the-services) to [Deploy and verify](#deploy-and-verify) do in the dashboard, with the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) and Ingot: it creates the database, the bucket and the application, adds the variables and the cron jobs, copies the data and the files, and deploys. A script or an AI agent runs it as is, in one shell, from the root of the repository, with the `sevalla` branch pushed and `main` the production branch that still has `vercel.json`. Each step names the [CLI](https://docs.sevalla.com/developer-tools/cli.md) command and the [Sevalla MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool that do the same. You need:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, in `SEVALLA_API_KEY`.
* The repository on GitHub, and a GitHub account with access to it connected to Sevalla. Without access, the step that creates the application fails with `400` and `Cannot access repository https://github.com/acme/storefront: Repository not found or you don't have access.`
* The Vercel CLI, signed in and linked, `copy-blob.mjs` from [Copy the Vercel Blob files](#copy-the-vercel-blob-files) in a folder of its own, here `../copy-blob`, with its packages installed, and `curl`, `jq`, `openssl`, `pg_dump`, `pg_restore` and `psql`.

Export the production variables, drop the ones Vercel and its integrations set, and replace each `[SENSITIVE]` in `sevalla.env` with the real value:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
vercel env pull vercel.env --environment=production
grep -vE '^(#|VERCEL|TURBO_|NX_DAEMON|BLOB_|DATABASE_URL|PG|POSTGRES_|NEON_|VITE_NEON_)' vercel.env \
  | sed -E 's/^([^=]+)="(.*)"$/\1=\2/' > sevalla.env
API=https://api.sevalla.com/v3
REPO=https://github.com/acme/storefront
BRANCH=sevalla
```

Both files contain secrets: don't commit them.

**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`. Save **Ashburn, USA**, the one next to Vercel's default `iad1`:

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

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

**2. Look up the cron job size**

[List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`). Save **H1**, the smallest, for the cron jobs:

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

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

**3. Look up the database size**

[List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`). Pick a size with room for the data; here **DB1**, the smallest:

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

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

**4. Look up the PostgreSQL version**

[List database versions](https://api-docs.sevalla.com/v3/resources/list-database-versions) (`listDatabaseVersionsV3`) returns the versions the location offers, oldest first. Save the newest, which is at least as new as Neon's:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
PG_VERSION=$(curl -s "$API/resources/database-versions?cluster_id=$CLUSTER_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.type == "postgresql") | .versions[-1]')
```

CLI: `sevalla resources db-versions --cluster "$CLUSTER_ID"`. MCP: not needed, `create_database` takes the newest version by default.

**5. Create the PostgreSQL database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`). You choose the password, and the API never returns it, so keep it for the connection URL:

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

CLI: `sevalla databases create --name storefront-db --type postgresql --db-version "$PG_VERSION" --cluster "$CLUSTER_ID" --resource-type "$DB_SIZE_ID" --db-name storefront --db-user storefront --db-password "$PG_PASSWORD"`. MCP: `create_database`.

**6. Create the bucket**

[Create object storage](https://api-docs.sevalla.com/v3/object-storages/create-object-storage-bucket) (`createObjectStorageV3`) with public access, in `enam` (Eastern North America). The response has the bucket's name, public domain, endpoint and keys:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
BUCKET=$(curl -s -X POST "$API/object-storage" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d '{"display_name": "storefront-files", "location": "enam", "public_access": true}')
echo "$BUCKET" | jq -r .domain
```

It prints the domain, such as `storefront-files-x7k2m.sevalla.storage`. CLI: `sevalla object-storage create --name storefront-files --location enam`, then `sevalla object-storage cdn-domain enable <bucket-id>`. MCP: `sevalla_api_create` with `createObjectStorageV3` and the same body.

**7. Wait for the database**

[Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) shows `status` `ready` when the database is up, with its internal hostname and port:

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

It prints `ready`. CLI: `sevalla databases get "$PG_ID"`. MCP: `get_database`.

**8. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates the application with a web process at **S1**, which can have custom domains, and doesn't deploy it. Its `name` makes up the web process's internal hostname:

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

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

**9. 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 "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg db "$PG_ID" '{target_id: $db, target_type: "database"}')" | jq -r .id
```

The API adds no environment variables; the next step adds `DATABASE_URL`. CLI: `sevalla apps internal-connections create "$APP_ID" --target-id "$PG_ID" --target-type database`. MCP: `sevalla_api_create` with `createApplicationInternalConnectionV3` and the same body.

**10. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per variable: `DATABASE_URL` in the format the internal connection uses, the bucket's values, `CRON_SECRET` and `APP_URL` for the cron script, then every line of `sevalla.env`. `CRON_SECRET` gets a new value unless `sevalla.env` has one:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
grep -q '^CRON_SECRET=' sevalla.env || echo "CRON_SECRET=$(openssl rand -hex 24)" >> sevalla.env
{
  jq -n --arg host "$(echo "$PG" | jq -r .internal_hostname)" --arg port "$(echo "$PG" | jq -r .internal_port)" --arg password "$PG_PASSWORD" \
    '{key: "DATABASE_URL", value: "postgres://storefront:\($password)@\($host):\($port)/storefront"}'
  echo "$BUCKET" | jq -c '{key: "S3_ENDPOINT", value: .endpoint}, {key: "S3_BUCKET", value: .bucket_name},
    {key: "S3_ACCESS_KEY_ID", value: .access_key}, {key: "S3_SECRET_ACCESS_KEY", value: .secret_key},
    {key: "S3_PUBLIC_URL", value: ("https://" + .domain)}'
  jq -n --arg name "$APP_NAME" '{key: "APP_URL", value: "http://\($name)-web.\($name).svc.cluster.local:8080"}'
  while IFS='=' read -r key value; do
    jq -n --arg key "$key" --arg value "$value" '{key: $key, value: $value}'
  done < sevalla.env
} | jq -c '. + {is_runtime: true, is_buildtime: true}' | while IFS= read -r body; do
  curl -s -X POST "$API/applications/$APP_ID/env-vars" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" -d "$body" | jq -r .key
done
```

It prints each key as it's added. CLI: `sevalla apps env-vars create "$APP_ID" --key <key> --value <value>`, once per variable. MCP: `set_environment_variables` with all the variables in one call.

**11. Create the cron jobs**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) with `type` `cron`, once per entry in the `crons` of `vercel.json`, read from `main`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
git show main:vercel.json | jq -c '.crons[]' | while IFS= read -r cron; do
  curl -s -X POST "$API/applications/$APP_ID/processes" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
    -d "$(echo "$cron" | jq --arg size "$H1_ID" '{display_name: ("Cron " + .path), type: "cron",
      entrypoint: ("node scripts/cron.mjs " + .path), schedule: .schedule,
      resource_type_id: $size, scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
    | jq -c '{display_name, schedule, entrypoint}'
done
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"display_name":"Cron /api/cron","schedule":"0 5 * * *","entrypoint":"node scripts/cron.mjs /api/cron"}
```

CLI: `sevalla apps processes create "$APP_ID" --name "Cron /api/cron" --type cron --resource-type "$H1_ID" --command "node scripts/cron.mjs /api/cron" --schedule "0 5 * * *"`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**12. Open the database to your computer**

[Toggle external connection](https://api-docs.sevalla.com/v3/databases/toggle-external-connection) (`toggleDatabaseExternalConnectionV3`) turns it on. Then [Get database](https://api-docs.sevalla.com/v3/databases/get-database) returns `external_hostname` and `external_port` once it's ready:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "$API/databases/$PG_ID/external-connection/toggle" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .message
for i in $(seq 1 30); do
  PG=$(curl -s "$API/databases/$PG_ID" -H "Authorization: Bearer $SEVALLA_API_KEY")
  [ -n "$(echo "$PG" | jq -r '.external_hostname // empty')" ] && break
  sleep 5
done
SEVALLA_PG="postgres://storefront:$PG_PASSWORD@$(echo "$PG" | jq -r .external_hostname):$(echo "$PG" | jq -r .external_port)/storefront"
for i in $(seq 1 30); do pg_isready -q -t 5 -d "$SEVALLA_PG" && break; sleep 5; done
```

The first command prints `External connection toggle started`. The hostname appears before the database accepts connections on it, so the last loop waits for `pg_isready`. Keep the address in a URL rather than in `PGHOST` and `PGPORT`: `psql` and `pg_dump` apply those to every connection without its own port, including Neon's. CLI: `sevalla databases connections external-toggle "$PG_ID"`. MCP: `sevalla_api_create` with `toggleDatabaseExternalConnectionV3`.

**13. Copy the data**

Pause the Vercel project as in [Copy the PostgreSQL data](#copy-the-postgresql-data), then dump the Neon database and restore it into the Sevalla one:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pg_dump "$(sed -n 's/^DATABASE_URL_UNPOOLED=//p' vercel.env | tr -d '"')" \
  --format=custom --no-owner --no-privileges --exclude-schema=neon_auth --file=vercel.dump
pg_restore --no-owner --no-privileges --jobs=4 --dbname="$SEVALLA_PG" vercel.dump
```

Both print nothing when they succeed. Without the data, the deployment fails: `next build` can't prerender the ISR pages.

**14. Copy the files**

Run `copy-blob.mjs` with the Blob token and the bucket's values:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
BLOB_READ_WRITE_TOKEN="$(sed -n 's/^BLOB_READ_WRITE_TOKEN=//p' vercel.env | tr -d '"')" \
S3_ENDPOINT=$(echo "$BUCKET" | jq -r .endpoint) S3_BUCKET=$(echo "$BUCKET" | jq -r .bucket_name) \
S3_ACCESS_KEY_ID=$(echo "$BUCKET" | jq -r .access_key) S3_SECRET_ACCESS_KEY=$(echo "$BUCKET" | jq -r .secret_key) \
node ../copy-blob/copy-blob.mjs
```

It prints the number of files, such as `Copied 6 files`.

**15. Point the stored URLs at the bucket**

Replace the Blob store's domain with the bucket's in each column that stores a file URL, here `products.image_url` and `uploads.url`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
OLD=$(psql "$(sed -n 's/^DATABASE_URL_UNPOOLED=//p' vercel.env | tr -d '"')" -Atc \
  "SELECT substring(url from '^https://[^/]+') FROM uploads LIMIT 1")
NEW="https://$(echo "$BUCKET" | jq -r .domain)"
psql "$SEVALLA_PG" -v old="$OLD" -v new="$NEW" <<'SQL'
UPDATE products SET image_url = replace(image_url, :'old', :'new');
UPDATE uploads SET url = replace(url, :'old', :'new');
SQL
```

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

**16. Deploy**

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

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

CLI: `sevalla apps deployments trigger "$APP_ID"`. MCP: `trigger_deployment`.

**17. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) reports the `status`, which ends as `success`, `failed`, `cancelled` or `skipped`. The loop polls for up to 30 minutes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.status // empty')
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

CLI: `sevalla apps deployments get "$DEPLOYMENT_ID" --app-id "$APP_ID"`. MCP: `get_deployment`.

**18. Read the build log**

[Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) returns the build log, oldest line first. When the status is `failed`, the last lines say why; `limit=5000`, the most it allows, returns the whole log:

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

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
🚀 Web has been deployed successfully.
🚀 Cron /api/cron has been deployed successfully.
```

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

**19. Check the application**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the application's domain. Request the products API from it:

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

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"count":6,"first":"Coffee mug"}
```

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

**20. Run a cron job**

[Trigger cron job](https://api-docs.sevalla.com/v3/applications/trigger-cron-job) (`triggerProcessCronV3`) starts one run now, and [List cron runs](https://api-docs.sevalla.com/v3/applications/list-cron-runs) (`listProcessCronRunsV3`) reports it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CRON_ID=$(curl -s "$API/applications/$APP_ID/processes" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '[.data[] | select(.type == "cron")][0].id')
curl -s -X POST "$API/applications/$APP_ID/processes/$CRON_ID/trigger" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .message
for i in $(seq 1 30); do
  RUN=$(curl -s "$API/applications/$APP_ID/processes/$CRON_ID/runs" -H "Authorization: Bearer $SEVALLA_API_KEY" \
    | jq -r '.data[0].status // empty')
  case $RUN in succeeded|failed) break ;; esac
  sleep 5
done
echo "$RUN"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
Cron job triggered
succeeded
```

CLI: `sevalla apps processes trigger "$CRON_ID" --app-id "$APP_ID"`, then `sevalla apps processes runs --app-id "$APP_ID" --process-id "$CRON_ID"`. MCP: `sevalla_api_create` with `triggerProcessCronV3`.

**21. Turn on auto-deploy**

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

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

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

Then run the checks in [Deploy and verify](#deploy-and-verify), and turn the external connection off with the same toggle call. To run more than one instance, create a Redis database the same way with `type` `redis`, connect it, add `REDIS_URL` as `redis://:<password>@<internal hostname>:<internal port>/0`, push the [cache handler](#share-the-next-js-cache-between-instances), and set the web process's instances with [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) and `{"scaling_strategy": {"type": "manual", "config": {"instanceCount": 2}}}` before you deploy. [Add application domain](https://api-docs.sevalla.com/v3/applications/add-application-domain) (`addApplicationDomainV3`) adds each custom domain and returns the DNS records in `dns_records`, about 5 seconds later in [List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains).


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