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

> Move a Netlify site to Sevalla: netlify.toml, _redirects and _headers, build variables, deploy previews and Functions, then export form submissions and switch the domains.

A Netlify site moves to Sevalla as a static site, which builds from the same repository and reads the same `_redirects` and `_headers` files, plus an application that runs its Netlify Functions. You set up Sevalla while Netlify keeps serving the site, move the rules that `netlify.toml` holds into those two files, then point the domains at Sevalla. Netlify Forms and Netlify Identity have no equivalent on Sevalla.

## At a glance

| | |
| - | - |
| Source | The site's Git repository, on GitHub, GitLab or Bitbucket |
| Build | Static site: the build command, publish directory and Node.js version from `netlify.toml`, such as `npm run build`, `dist` and **Node 22**. Functions: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Node.js 24, npm), with the **Build path** `api` |
| Start command | Functions: `node server.mjs`, from `scripts.start` in `api/package.json` |
| Port | Functions: the `PORT` environment variable, `8080` by default |
| Processes | Functions: web process |
| Services | A static site, and an application for the functions |
| Disk | None |
| Size | Functions: **H1** for the web process, or **S1** if it gets a custom domain. A static site has no size. |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| The site's build variables | As on Netlify | Build, for **Production**, **Preview** or both | As on Netlify | From `netlify env:list`, without `NODE_VERSION` |
| `URL` | If the build reads it | Build | No | The site's address, such as `https://shop.example.com`. Netlify sets it during builds; Sevalla doesn't |
| `PUBLIC_API_URL` | If the site calls functions | Build | No | The functions application's address, such as `https://storefront-api-k3x9p.sevalla.app` |
| The functions' variables, such as `GREETING` | As on Netlify | Runtime | As on Netlify | From `netlify env:list` |
| `ALLOWED_ORIGIN` | No | Runtime | No | The one origin allowed to call the functions from a browser, such as `https://shop.example.com`. `*` (any origin) when unset |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port. Don't add it |

## How Netlify maps to Sevalla

Netlify's dashboard calls a site a project; this page says site, as Netlify's documentation does.

| Netlify | Sevalla | Notes |
| - | - | - |
| Site | [Static site](https://docs.sevalla.com/static-sites/overview.md) | Built from a Git repository and served from Cloudflare's edge |
| `[build]` in `netlify.toml`: `command`, `publish`, `base` | [Build settings](https://docs.sevalla.com/static-sites/build-settings.md): **Build command**, **Publish directory**, **Root directory** | Sevalla doesn't read `netlify.toml` |
| `NODE_VERSION`, `.nvmrc` | The **Node version** build setting | **Latest LTS**, **Node 22**, **Node 20**, **Node 18** or **Node 16** |
| Environment variables and deploy contexts | [Environment variables](https://docs.sevalla.com/static-sites/environment-variables.md) for **Production** and **Preview** | `deploy-preview` and `branch-deploy` become **Preview**; the API's `branch` field limits a variable to one branch |
| Read-only build variables: `URL`, `CONTEXT`, `DEPLOY_PRIME_URL`, `COMMIT_REF` | None | Add the ones your build reads as variables |
| `_redirects`, `[[redirects]]` | [`_redirects`](https://docs.sevalla.com/static-sites/redirects) | The same line format. See [Rule differences](#rule-differences) |
| `_headers`, `[[headers]]` | [`_headers`](https://docs.sevalla.com/static-sites/headers) | The same format |
| `404.html` | A `/* /404.html 404` rule | Sevalla serves its own 404 page without one |
| Pretty URLs | [Pretty URLs](https://docs.sevalla.com/static-sites/redirects.md#pretty-urls) | Off for new sites. See [Trailing slashes](#trailing-slashes) |
| Deploy Previews, branch deploys | [Pull request previews](https://docs.sevalla.com/static-sites/deployments.md#pull-request-previews) | Every push to a branch other than the default branch |
| Production branch, auto publishing | [Auto-deploy](https://docs.sevalla.com/static-sites/deployments.md#deploy-automatically-on-push) | |
| Build hooks | [Deploy static site](https://api-docs.sevalla.com/v3/static-sites/deploy-static-site) | An API call with an [API key](https://docs.sevalla.com/developer-tools/api-keys.md) |
| Publishing an earlier deploy | [Deploy static site](https://api-docs.sevalla.com/v3/static-sites/deploy-static-site) with `deployment_id` | Rebuilds the commit of an earlier deployment |
| Deploy notifications | [Webhooks](https://docs.sevalla.com/developer-tools/webhooks.md) | The `STATIC_SITE_DEPLOY` event |
| Functions | [Application](https://docs.sevalla.com/applications/overview.md) | See [Move the functions to an application](#move-the-functions-to-an-application) |
| Scheduled Functions | [Cron job](https://docs.sevalla.com/applications/processes.md#cron-jobs) in that application | Schedules run in UTC on both |
| Background Functions | The functions application | A long-running server: work can go on after the response |
| Edge Functions, Image CDN, Split Testing, password protection | None | Move the logic into an application, or drop it |
| Netlify Blobs | [Object storage](https://docs.sevalla.com/object-storage/overview.md) | S3-compatible buckets: your code changes to an S3 client |
| Forms | None | See [Export the form submissions](#export-the-form-submissions) |
| Identity, role-based redirects | None | See [Replace Netlify Identity](#replace-netlify-identity) |
| Analytics | [Analytics](https://docs.sevalla.com/static-sites/analytics.md) | |
| Custom domains, HTTPS | [Custom domains](https://docs.sevalla.com/domains/overview.md) | Sevalla issues and renews the SSL certificates |
| Netlify DNS | Your DNS provider | Sevalla doesn't host DNS zones |
| Netlify CLI and API | [Sevalla CLI](https://docs.sevalla.com/developer-tools/cli.md) and [API](https://docs.sevalla.com/developer-tools/api.md) | |
| Team members | [Users and roles](https://docs.sevalla.com/company/users-and-roles.md) | |

## Before you begin

* The [Netlify CLI](https://docs.netlify.com/api-and-cli-guides/cli-guides/get-started-with-cli/), signed in, and the repository linked to the site with `netlify link`.
* The site's code in a repository on GitHub, GitLab or Bitbucket. See [Git providers](https://docs.sevalla.com/git/overview.md).
* A Sevalla company with a payment method. Static sites are free up to 100 GB of bandwidth and 600 build minutes a month; the functions application is billed by its size. See [Pricing](https://docs.sevalla.com/billing/pricing.md).
* `jq`, for the commands that read the Netlify CLI's JSON.

## Take stock of the Netlify site

Run these in the linked repository:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
netlify status
netlify env:list --plain --context production > netlify.env
netlify env:list --plain --context deploy-preview > netlify-preview.env
netlify functions:list
SITE_ID=$(netlify status --json | jq -r '.siteData["site-id"]')
netlify api listSiteForms --data "{\"site_id\":\"$SITE_ID\"}" | jq -r '.[] | "\(.name) \(.submission_count)"'
netlify api getSite --data "{\"site_id\":\"$SITE_ID\"}" | jq '{custom_domain, domain_aliases}'
```

* `netlify.toml` holds the build settings (`[build]`), the build variables of each deploy context, and `[[redirects]]` and `[[headers]]` rules. Keep it open: Sevalla doesn't read it.
* `netlify.env` and `netlify-preview.env` hold every variable as `KEY=value` for production and for Deploy Previews, `netlify.toml` values included. A secret variable's value shows masked, such as `****************_123`: take it from your own records. Both files contain secrets: don't commit them.
* `functions:list` lists the functions in the functions folder, `netlify/functions` unless `netlify.toml` sets another. A function with `path` in its `config` answers on that path; the others on `/.netlify/functions/<name>`.
* `listSiteForms` prints each Netlify Form with its number of submissions, such as `contact 4`. Export them before you delete the site.
* `getSite` lists the custom domains to move.
* A site whose `package.json` has a Netlify adapter, such as `@astrojs/netlify`, renders pages on the server. See [Move a site with server-side rendering](#move-a-site-with-server-side-rendering).

## Move the netlify.toml rules

Netlify reads `_redirects` and `_headers` as well as `netlify.toml`, so you can move the rules now and push: Netlify serves the site the same way while you set up Sevalla.

**1. Move the redirects**

Write each `[[redirects]]` table as a line at the end of `_redirects`, in the publish directory or the folder your generator copies to it (`public/` for Astro and Vite). Netlify applies `_redirects` rules before `netlify.toml` rules, so appending them keeps the order. Then add the 404 rule last:

```toml netlify.toml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
[[redirects]]
  from = "/shop/*"
  to = "/store/:splat"
  status = 301

[[redirects]]
  from = "/catalog"
  to = "/store/:category/"
  status = 302
  query = {category = ":category"}
```

```text _redirects theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# Moved from netlify.toml
/shop/*     /store/:splat                          301
/catalog    category=:category  /store/:category/  302

# Netlify serves 404.html without a rule
/*          /404.html                              404
```

`force = true` becomes `!` after the status, such as `301!`. `query` pairs go between `from` and `to`. Drop `conditions`, `headers` and `signed`: see [Rule differences](#rule-differences).

**2. Move the headers**

Write each `[[headers]]` table as a path line in `_headers`, with its values indented below it:

```toml netlify.toml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
[[headers]]
  for = "/store/*"
  [headers.values]
    X-Section = "store"
    Cache-Control = "public, max-age=600"
```

```text _headers theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# Moved from netlify.toml
/store/*
  X-Section: store
  Cache-Control: public, max-age=600
```

**3. Remove the tables from netlify.toml**

Delete the `[[redirects]]` and `[[headers]]` tables, so each rule exists once. Keep `[build]` and the variables until you shut down the Netlify site.

**4. Fix what works differently**

Go through [Rule differences](#rule-differences) and change the rules it lists, then commit and push.

### Rule differences

Sevalla reads Netlify's `_redirects` and `_headers` formats, and most rules work unchanged: status codes `301`, `302`, `307` and `200`, splats, placeholders, `!`, rewrites to a file, and custom 404 pages for one folder. These differ:

| On Netlify | On Sevalla | What to change |
| - | - | - |
| `404.html` answers every URL without a file | Sevalla's own 404 page | Add `/* /404.html 404` as the last rule |
| `[[redirects]]` and `[[headers]]` in `netlify.toml` | Not read | Move them to `_redirects` and `_headers` |
| A rule with query parameters also matches a request with more parameters, and passes the query string on: `/search q=:term /results/:term/ 302` sends `/search?q=laptops&sort=asc` to `/results/laptops/?q=laptops&sort=asc` | The request must have exactly the listed parameters, and the query string isn't passed on: `/search?q=laptops` goes to `/results/laptops/` | Add a rule for each combination, most parameters first, and put the values you need in `to` |
| A rewrite (`200`) to a folder, such as `/pricing-2026/`, serves its `index.html` | Not found | Write the file: `/pricing-2026/index.html` |
| Any status code, such as `410` | Only `200`, `301`, `302`, `303`, `307`, `308` and `404`. A rule with another code is skipped | Use `404` with a page, such as `/gone /404.html 404`, or redirect to the replacement |
| A proxy (`200` to a full URL) sends the request's method, headers and body | Sends the method only, without the headers or body, and caches `GET` and `HEAD` responses for 30 days | Proxy only public content that can be cached. Call APIs from the browser, as in [Move the functions to an application](#move-the-functions-to-an-application) |
| `Country=`, `Language=` and `Cookie=` conditions | Ignored: the rule applies to every request | Remove the rule |
| `Role=` conditions and `401` rules, with Netlify Identity | `Role=` is ignored and `401` isn't supported | Remove them. Every file of a static site is public: see [Replace Netlify Identity](#replace-netlify-identity) |
| Paths are case-sensitive: `/Old-Page` doesn't match `/old-page` | Case doesn't matter | Nothing, unless two rules differ only by case |
| A header path matches with or without a trailing slash: `/posts/:slug` matches `/posts/launch/` | The path must match as requested, slash included | Write the path as the page is served, such as `/posts/:slug/`, or `/posts/*` |
| Two matching rules that set the same header send one value | Both values, separated by `, ` | Set each header in one rule only |
| `Basic-Auth` lines protect paths with a password, on paid plans | Sent to browsers as a response header, password included | Remove every `Basic-Auth` line |
| Every file gets `Cache-Control: public,max-age=0,must-revalidate` | HTML files get `public, max-age=0, s-maxage=2592000`; other files `public, max-age=2592000, s-maxage=2592000`, kept by browsers for 30 days | Set `Cache-Control` for files that keep their names, such as `/styles.css`. See [Cache files that keep their names](https://docs.sevalla.com/static-sites/headers.md#cache-files-that-keep-their-names) |

> [!WARNING]
>
> A `Basic-Auth` line copied from Netlify sends the user name and password to every visitor in a response header. Remove it before the first deployment on Sevalla.

### Trailing slashes

Netlify's Pretty URLs are on by default: `/about` redirects to `/about/` when `about/index.html` exists. Sevalla's [pretty URLs](https://docs.sevalla.com/static-sites/redirects.md#pretty-urls) are off for new sites, and `/about` serves `about/index.html` directly, with status `200`. To get Netlify's redirects, [turn pretty URLs on](https://docs.sevalla.com/static-sites/redirects.md#pretty-urls) on the static site's **Settings** tab, then purge the cache. With them on, Sevalla adds the trailing slash to every URL without a file extension before it applies your rules, so `/old-page` first redirects to `/old-page/`, then follows your rule. Pull request previews don't use pretty URLs.

## Move the functions to an application

A static site serves files only, so Netlify Functions move to a small Node.js application that runs them unchanged. A function's default export takes a web `Request` and returns a `Response`, which is what a fetch-style server such as [`@hono/node-server`](https://github.com/honojs/node-server) calls.

**1. Move the functions into their own folder**

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mkdir api
git mv netlify/functions api/functions
```

So that Netlify keeps deploying them from there until you shut it down, add to `netlify.toml`:

```toml netlify.toml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
[functions]
  directory = "api/functions"
```

**2. Add the server**

Add `api/package.json` and `api/server.mjs`, with one `routes` entry per function: its `config.path`, or `/.netlify/functions/<name>`:

```json api/package.json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "name": "storefront-api",
  "private": true,
  "type": "module",
  "engines": {
    "node": "24.x"
  },
  "scripts": {
    "start": "node server.mjs"
  },
  "dependencies": {
    "@hono/node-server": "^2.1.5"
  }
}
```

```js api/server.mjs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { serve } from "@hono/node-server";
import hello from "./functions/hello.mjs";

// One entry per function: the path in its config, or /.netlify/functions/<name>.
const routes = {
  "/api/hello": hello,
};

const cors = {
  "Access-Control-Allow-Origin": process.env.ALLOWED_ORIGIN ?? "*",
  "Access-Control-Allow-Headers": "Content-Type",
};

serve({
  port: Number(process.env.PORT ?? 8080),
  async fetch(req) {
    if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
    const fn = routes[new URL(req.url).pathname];
    const res = fn ? await fn(req, {}) : new Response("Not found", { status: 404 });
    const out = new Response(res.body, res);
    for (const [name, value] of Object.entries(cors)) out.headers.set(name, value);
    return out;
  },
});
```

Install the dependency, which writes `api/package-lock.json`, then commit and push:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
cd api && npm install && cd ..
git add api netlify.toml
git commit -m "Run the functions as an application"
git push
```

The server answers `OPTIONS` requests and adds CORS headers, because the site and the functions now have different origins. Functions that read the second argument, `context` (`context.params`, `context.geo`, `context.ip`, `context.cookies`), or call `Netlify.env.get()`, need a change: read `process.env` and the request's headers instead. A function in the older format, `exports.handler = async (event) => ...`, works once you wrap it: `export default withLambda(handler)`, with `withLambda` from `@netlify/aws-lambda-compat`.

**3. 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 a **Data center** close to your visitors, keep the **Size** at **H1**, and click **Create application**. See [Create an application](https://docs.sevalla.com/applications/create.md).

**4. Build from the api folder**

On the application's **Settings** tab, in the **Build strategy** section, click **Update build strategy**. Keep **Ingot**, set the **Build path** to `api`, and click **Update build strategy**.

**5. Add the functions' variables**

Click the **Environment variables** tab, then click **Add environment variables**. Add the variables your functions read, such as `GREETING`, and click **Add environment variables**. To let only your site call the functions from a browser, add `ALLOWED_ORIGIN` with its origin, such as `https://shop.example.com`; pull request previews have other origins, so leave it out while you use them. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

**6. Deploy**

On the **Overview** tab, click **Deploy now** under **Recent deployments**, check the **Branch**, and click **Deploy**. When the status on the **Deployments** tab is **Deployed**, copy the domain under the application's name, such as `storefront-api-k3x9p.sevalla.app`.

The build log shows Ingot's build plan for the `api` folder:

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

The function answers as it did on Netlify:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "https://storefront-api-k3x9p.sevalla.app/api/hello?name=Ada"
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"message":"Hello, Ada!","method":"GET"}
```

Then make the site call the application. Prefix every function URL in the site's code with a build variable, `PUBLIC_API_URL` here, which stays unset on Netlify, so relative URLs keep working there:

```astro src/pages/index.astro theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
---
const apiUrl = import.meta.env.PUBLIC_API_URL ?? "";
---
<p id="greeting" data-api={apiUrl}></p>
<script>
  const el = document.getElementById("greeting");
  fetch(`${el.dataset.api}/api/hello?name=visitor`)
    .then((res) => res.json())
    .then((data) => { el.textContent = data.message; });
</script>
```

Use your framework's prefix for variables that reach the browser: `PUBLIC_` for Astro, `VITE_` for Vite, `NEXT_PUBLIC_` for Next.js. A `_redirects` proxy rule such as `/api/* https://storefront-api-k3x9p.sevalla.app/api/:splat 200` isn't a replacement: Sevalla drops the request's body and headers and caches the responses.

## Create the static site

**1. Clean up the exported variables**

In `netlify.env` and `netlify-preview.env`, delete `NODE_VERSION` (it becomes the **Node version** setting) and the variables only your functions read, such as `GREETING`. Replace masked secret values with the real ones. Add `PUBLIC_API_URL` with the application's address to both, and, if your build reads `URL` (Astro's `site`, sitemaps, canonical links), `URL` with the site's address.

**2. Open the create form**

In the sidebar, click **Static sites**, then click **Create static site**. Under **Source**, click **Private repository**, then choose the **Git provider**, the **Repository** and the **Branch**, the one Netlify deploys to production.

**3. Choose how it deploys**

Leave **Auto-deploy** on, and turn on **Pull request previews** if the site uses Deploy Previews.

**4. Copy the build settings**

Under **Details**, enter a **Name**. Under **Build settings**, fill in the values from `netlify.toml`:

| `netlify.toml` | Sevalla |
| - | - |
| `command` | **Build command**, such as `npm run build` |
| `publish` | **Publish directory**, such as `dist` |
| `base` | **Root directory**. Empty for the repository root |
| `NODE_VERSION` | **Node version**, such as **Node 22**. **Latest LTS** for a newer major version |

**5. Import the variables**

Under **Environment variables**, click **Import .env** and choose `netlify.env`. With previews on, select **Production** only.

**6. Create it**

Click **Create static site**. The site opens on its **Overview** tab and its first deployment starts. See [Create a static site](https://docs.sevalla.com/static-sites/create.md).

Sevalla doesn't set Netlify's read-only variables, such as `URL`, `CONTEXT`, `DEPLOY_PRIME_URL` or `COMMIT_REF`. A build that reads one gets nothing unless you add it: with `site: process.env.URL` in `astro.config.mjs`, Astro leaves out canonical links until the `URL` variable exists.

## Set up pull request previews

Netlify builds a Deploy Preview for each pull request; Sevalla builds a pull request preview for every push to a branch other than the site's default branch, and comments on the pull request with links to it.

| Netlify | Sevalla |
| - | - |
| `deploy-preview-<number>--<site>.netlify.app`, `<branch>--<site>.netlify.app` | `<name>-branch-<branch>.sevalla.page`, the branch's latest preview |
| `<deploy ID>--<site>.netlify.app` | `<name>-commit-<commit>.sevalla.page`, one commit |
| Variables of the `deploy-preview` and `branch-deploy` contexts | Variables marked **Preview** |

**1. Add the preview variables**

On the static site's **Environment variables** tab, click **Add environment variables**, click **Import .env** and choose `netlify-preview.env`. Select **Preview** only, and click **Add environment variables**. Previews build with the variables marked **Preview** only, not with the production ones, so this file holds every variable a preview needs. A key can exist once for production and once for previews, with different values.

**2. Open a pull request**

Push a branch and open a pull request. Sevalla's comment, `Preview deployments for <display name>`, links to the branch and commit previews. See [Pull request previews](https://docs.sevalla.com/static-sites/deployments.md#pull-request-previews).

## Export the form submissions

Netlify Forms has no equivalent: a static site on Sevalla doesn't process form posts, and discards what a form sends to it. Export the submissions while the Netlify site exists, and give each form another destination before you move the domain.

**1. Export the submissions**

In Netlify, open the site's **Forms**, choose a form, and click **Download as CSV**. Or, with the CLI, save every submission of the site as one JSON line, 100 per request:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for page in $(seq 1 1000); do
  netlify api listSiteSubmissions --data "{\"site_id\":\"$SITE_ID\",\"per_page\":100,\"page\":$page}" > page.json
  [ "$(jq length page.json)" -eq 0 ] && break
  jq -c '.[]' page.json >> submissions.jsonl
done
wc -l < submissions.jsonl
```

The count matches the sum of the forms' submissions from [Take stock of the Netlify site](#take-stock-of-the-netlify-site), such as `4`. Each line has the form's fields in `data`. The file contains personal data: keep it out of the repository.

**2. Send the form somewhere else**

Point each form's `action` at an endpoint that stores or forwards the submission: a route in the functions application that writes to a [Sevalla database](https://docs.sevalla.com/databases/overview.md) or sends an email, or a form-handling service. Remove `data-netlify="true"`, `netlify-honeypot` and the hidden `form-name` field, which only Netlify reads.

## Replace Netlify Identity

Netlify Identity, and the `Role=` redirect conditions that rely on it, have no equivalent: every file a static site publishes is public. Before you move the domain, move sign-in to an authentication provider, or to an [application](https://docs.sevalla.com/applications/overview.md) that checks the session and serves the protected pages itself.

## Deploy and verify

**1. Check the deployment**

On the static site's **Deployments** tab, wait for the status **Deployed**. The deployment's log shows ``🔍 `_redirects` file found!``, then `Redirects processed successfully!`, and the same for `_headers`. If a line can't be parsed, the log shows `There were errors while parsing your redirect rules:` and the deployment is published without any of the file's rules.

**2. Compare the pages**

On the **Overview** tab, click **Visit site** and compare it with the Netlify site. From a terminal, with your site's domain:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://storefront-x7k2m.sevalla.page/ | grep -o '<title>[^<]*</title>'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<title>Home | Acme Store</title>
```

**3. Check the rules**

Request the URLs that your rules handle and compare them with Netlify. For the rules moved from `netlify.toml`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -sI https://storefront-x7k2m.sevalla.page/shop/headphones/ | grep -i '^location'
curl -sI https://storefront-x7k2m.sevalla.page/store/ | grep -i '^x-section'
curl -s -o /dev/null -w '%{http_code}\n' https://storefront-x7k2m.sevalla.page/no-such-page
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
location: https://storefront-x7k2m.sevalla.page/store/headphones/
x-section: store
404
```

With the API, [Get static site deployment](https://api-docs.sevalla.com/v3/static-sites/get-static-site-deployment) (`getStaticSiteDeploymentV3`) returns the status, `success` for **Deployed**, and [Get static site deployment logs](https://api-docs.sevalla.com/v3/static-sites/get-static-site-deployment-logs) (`getStaticSiteDeploymentLogsV3`) returns the log. See [Deploy with the API](#deploy-with-the-api).

## Move a site with server-side rendering

A site that renders pages on the server builds with a Netlify adapter, such as `@astrojs/netlify` or the Next.js runtime that Netlify adds, into Netlify Functions. On Sevalla it runs as an [application](https://docs.sevalla.com/applications/overview.md) with the framework's Node.js server, which serves its static files too, so it needs no static site.

For Astro, replace the adapter, then remove the leftover `import netlify from '@astrojs/netlify';` line from `astro.config.mjs`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npx astro add node --yes
npm uninstall @astrojs/netlify
```

`astro add node` sets `adapter: node({ mode: 'standalone' })`, which Ingot starts with `node dist/server/entry.mjs`. Run it before you uninstall the Netlify adapter: the other way round, `astro add` fails on the import of the missing package. With the Netlify adapter still in `package.json`, the build fails with `Astro builds for Netlify with @astrojs/netlify, which no server in the image can run`. Then deploy the repository as in [Render pages on demand](https://docs.sevalla.com/guides/javascript/astro.md#render-pages-on-demand).

For other frameworks, follow their guide: [Next.js](https://docs.sevalla.com/guides/javascript/nextjs.md), [Nuxt](https://docs.sevalla.com/guides/javascript/nuxt.md), [SvelteKit](https://docs.sevalla.com/guides/javascript/sveltekit.md) or [Remix](https://docs.sevalla.com/guides/javascript/remix.md). The application's variables replace the site's runtime variables, and `_redirects` and `_headers` rules move into the framework's own redirects and headers, because an application doesn't read those files.

## Move the custom domains

Netlify keeps serving each domain until you change its pointing record, and Sevalla issues the certificate before that, so visitors never see an error.

**1. Add each domain**

On the static site's **Domains** tab, click **Add custom domain**, enter the domain in **Domain name**, for example `shop.example.com`, and click **Add custom domain**. Its **DNS records** sheet opens.

**2. Verify it**

Where the domain's DNS is managed, add the two `TXT` records the sheet shows, `_cf-custom-hostname.shop.example.com` and `_acme-challenge.shop.example.com`. Don't change the record that points at Netlify yet. When the status changes to **Pointing**, the 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 Netlify, a `CNAME` to `<site>.netlify.app` for a subdomain, or an `ALIAS` to `apex-loadbalancer.netlify.com` or an `A` record with `75.2.60.5` for a root domain, with the `A` record that the sheet shows. The status changes to **Active** when Sevalla sees it. See [Point the domain](https://docs.sevalla.com/domains/overview.md#point-the-domain).

**4. Remove it from Netlify**

When the domain is **Active** and its old record has expired (its TTL), remove the domain from the Netlify site's domain management.

If the domain uses Netlify DNS, make these changes in its Netlify DNS zone (**DNS** in the team's sidebar, then the domain, then **Add new record**), and move the zone to another DNS provider, with every record, before you delete the site: Netlify DNS hosts records only while a Netlify site uses the domain. Update `URL` and `ALLOWED_ORIGIN` to the custom domain, then deploy the static site and the application.

## Shut down the Netlify site

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

1. Keep the exported form submissions and `netlify.env` until you no longer need them, outside the repository.
2. Delete the site with `netlify sites:delete "$SITE_ID"`. Its form submissions go with it, and its `netlify.app` address stops working.
3. Remove what only Netlify reads from the repository: `netlify.toml`, and Netlify packages such as `@netlify/functions`.

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| A redirect or header from `netlify.toml` has no effect | Sevalla doesn't read `netlify.toml` | [Move the netlify.toml rules](#move-the-netlify-toml-rules) |
| Missing pages show Sevalla's 404 page instead of yours | Netlify serves `404.html` without a rule; Sevalla doesn't | Add `/* /404.html 404` as the last line of `_redirects` |
| A rule with query parameters doesn't match | Sevalla needs exactly the listed parameters | Add a rule per combination. See [Rule differences](#rule-differences) |
| The build used another Node.js version | Sevalla ignores `NODE_VERSION`, `.nvmrc` and `engines` | Set the **Node version** [build setting](https://docs.sevalla.com/static-sites/build-settings.md) |
| Canonical links, the sitemap or Astro's `site` are missing | Netlify sets `URL` during builds; Sevalla doesn't | Add `URL` as a variable, then deploy |
| Calls to `/api/...` or `/.netlify/functions/...` return `404` | The functions aren't on the static site | Call the functions application with `PUBLIC_API_URL`. See [Move the functions to an application](#move-the-functions-to-an-application) |
| The browser blocks the call to the functions with a CORS error | `ALLOWED_ORIGIN` names another origin, such as the production domain on a preview | Set `ALLOWED_ORIGIN` to the calling origin, or remove it to allow any |
| Form submissions stop arriving | Netlify Forms doesn't exist on Sevalla | [Send the form somewhere else](#export-the-form-submissions) |
| The build fails with `Astro builds for Netlify with @astrojs/netlify, which no server in the image can run` | The application still has Netlify's adapter | [Replace the adapter](#move-a-site-with-server-side-rendering) |

## Deploy with the API

This sequence does what [Move the functions to an application](#move-the-functions-to-an-application), [Create the static site](#create-the-static-site) and [Set up pull request previews](#set-up-pull-request-previews) do in the dashboard, with the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md). It starts from the repository as it is after [Move the netlify.toml rules](#move-the-netlify-toml-rules) and the first two steps of [Move the functions to an application](#move-the-functions-to-an-application), pushed to `main`. A script or an AI agent runs it as is, in one shell, from the root of the linked repository. 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 CLI reads it from `SEVALLA_API_TOKEN`.
* The repository on GitHub, and a GitHub account with access to it connected to Sevalla. Without access, the steps that create the services fail with `400` and `Cannot access repository https://github.com/acme/storefront: Repository not found or you don't have access.`, followed by where to grant it.
* The Netlify CLI, signed in and linked to the site, plus `curl` and `jq`.

Export the variables as JSON, drop `NODE_VERSION` and the functions' variables from the site's files, and keep the functions' variables in their own file. Replace masked secret values in the files before you go on:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
netlify env:list --json --context production > netlify.json
netlify env:list --json --context deploy-preview > netlify-preview.json
jq 'del(.NODE_VERSION, .GREETING)' netlify.json > production.json
jq 'del(.NODE_VERSION, .GREETING)' netlify-preview.json > preview.json
jq '{GREETING}' netlify.json > functions.json
API=https://api.sevalla.com/v3
REPO=https://github.com/acme/storefront
SITE_URL=https://shop.example.com
```

The 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 the one for the functions, here Frankfurt, Germany:

```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 == "eu-frankfurt-1") | .id')
```

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

**2. Look up the H1 size**

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

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

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

**3. Create the functions application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates the application with a web process at **S1** 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 "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg cluster "$CLUSTER_ID" --arg repo "$REPO" \
    '{display_name: "storefront-api", cluster_id: $cluster, source: "privateGit", git_type: "github",
      repo_url: $repo, default_branch: "main"}')" \
  | jq -r .id)
```

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

**4. Build from the api folder**

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

```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 '{"build_path": "api"}' | jq -c '{build_type, build_path}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"build_type":"ingot","build_path":"api"}
```

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

**5. Set the size**

[List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) finds the web process, and [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) moves it to **H1**:

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

It prints `h1`. CLI: `sevalla apps processes update "$WEB_ID" --app-id "$APP_ID" --resource-type "$H1_ID"`. MCP: `scale_process`.

**6. Add the functions' variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per entry of `functions.json`, available at runtime:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -c 'to_entries[] | {key, value, is_runtime: true, is_buildtime: false}' functions.json | 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, here `GREETING`. CLI: `sevalla apps env-vars create "$APP_ID" --key GREETING --value <value>`, which also makes it available during the build. MCP: `set_environment_variables` with `runtime: true` and `buildtime: false`.

**7. Deploy the application**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`), then [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until the `status` is final, for up to 30 minutes:

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

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

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

**8. Check the functions**

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

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

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"message":"Hello, Ada!","method":"GET"}
```

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

**9. Create the static site**

[Create static site](https://api-docs.sevalla.com/v3/static-sites/create-static-site) (`createStaticSiteV3`) with the settings from `netlify.toml`, auto-deploy and pull request previews on. `"node_version": "22"` is **Node 22**. It doesn't start a deployment:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SITE_ID=$(curl -s -X POST "$API/static-sites" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg repo "$REPO" \
    '{display_name: "storefront", source: "privateGit", git_type: "github", repo_url: $repo,
      default_branch: "main", auto_deploy: true, is_preview_enabled: true,
      build_command: "npm run build", published_directory: "dist", node_version: "22"}')" \
  | jq -r .id)
```

CLI: `sevalla static-sites create --name storefront --source privateGit --git-type github --repository "$REPO" --branch main --build-command "npm run build" --publish-directory dist --node-version 22`, which leaves auto-deploy and previews off. MCP: `create_static_site` with the same fields and `deploy: false`.

**10. Add the build variables**

[Create static site environment variable](https://api-docs.sevalla.com/v3/static-sites/create-static-site-environment-variable) (`createStaticSiteEnvVarV3`), once per variable: `production.json` for production, `preview.json` for previews, and `URL` and `PUBLIC_API_URL` for both:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  jq -c 'to_entries[] | {key, value, is_production: true, is_preview: false}' production.json
  jq -c 'to_entries[] | {key, value, is_production: false, is_preview: true}' preview.json
  jq -n -c --arg url "$SITE_URL" '{key: "URL", value: $url, is_production: true, is_preview: true}'
  jq -n -c --arg url "https://$API_HOST" '{key: "PUBLIC_API_URL", value: $url, is_production: true, is_preview: true}'
} | while IFS= read -r body; do
  curl -s -X POST "$API/static-sites/$SITE_ID/env-vars" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" -d "$body" \
    | jq -r '"\(.key) production=\(.is_production) preview=\(.is_preview)"'
done
```

It prints each variable with where it applies, such as `PUBLIC_BANNER production=false preview=true`. Unlike Netlify, Sevalla doesn't give previews the production variables: a variable that both need is added for both. CLI: `sevalla static-sites env-vars create "$SITE_ID" --key <key> --value <value>`, which makes the variable available to production and previews. MCP: `set_environment_variables` with `scope: "static_site"`, `production` and `preview`.

**11. Deploy the static site**

[Deploy static site](https://api-docs.sevalla.com/v3/static-sites/deploy-static-site) (`deployStaticSiteV3`), then [Get static site deployment](https://api-docs.sevalla.com/v3/static-sites/get-static-site-deployment) (`getStaticSiteDeploymentV3`) until the `status` is final:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SITE_DEPLOYMENT_ID=$(curl -s -X POST "$API/static-sites/$SITE_ID/deployments" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" -d '{}' | jq -r .id)
for i in $(seq 1 120); do
  STATUS=$(curl -s "$API/static-sites/$SITE_ID/deployments/$SITE_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
```

CLI: `sevalla static-sites deployments trigger "$SITE_ID"`, then `sevalla static-sites deployments get "$SITE_DEPLOYMENT_ID" --site-id "$SITE_ID"`. MCP: `trigger_deployment`, then `get_deployment`, with `service_type` set to `static_site`.

**12. Check the rules files**

[Get static site deployment logs](https://api-docs.sevalla.com/v3/static-sites/get-static-site-deployment-logs) (`getStaticSiteDeploymentLogsV3`) returns the build log. `limit=5000` reads all of it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "$API/static-sites/$SITE_ID/deployments/$SITE_DEPLOYMENT_ID/logs?limit=5000" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message' | grep 'processed successfully'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
✅ Redirects processed successfully!
✅ Headers processed successfully!
```

CLI: `sevalla static-sites deployments logs "$SITE_DEPLOYMENT_ID" --site-id "$SITE_ID" --search processed`. MCP: `get_deployment_logs` with `service_type` set to `static_site`.

**13. Check the site**

[Get static site](https://api-docs.sevalla.com/v3/static-sites/get-static-site) (`getStaticSiteV3`) returns the site's domain in `hostname`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SITE_HOST=$(curl -s "$API/static-sites/$SITE_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .hostname)
curl -s "https://$SITE_HOST/" | grep -o '<title>[^<]*</title>'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<title>Home | Acme Store</title>
```

CLI: `sevalla static-sites get "$SITE_ID"`. MCP: `get_static_site`.

Then run the checks in [Deploy and verify](#deploy-and-verify). [Toggle pretty URLs](https://api-docs.sevalla.com/v3/static-sites/toggle-pretty-urls) (`toggleStaticSitePrettyUrlV3`) turns on Netlify's trailing-slash redirects (CLI: `sevalla static-sites pretty-url-toggle "$SITE_ID"`). [Add static site domain](https://api-docs.sevalla.com/v3/static-sites/add-static-site-domain) (`addStaticSiteDomainV3`) adds each custom domain with `{"domain_name": "shop.example.com"}`, and [List static site domains](https://api-docs.sevalla.com/v3/static-sites/list-static-site-domains) (`listStaticSiteDomainsV3`) returns the records to add in `dns_records`: `verification` and `ssl` (the two `TXT` records) and `origin` (the `A` record).


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