> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sevalla.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Deploy a Statamic site

> Deploy a Statamic 6 site to Sevalla with its flat-file content in Git: Ingot builds it as Laravel, FrankenPHP serves it, and the control panel signs in over HTTPS.

A Statamic site runs on Sevalla as an application built from your Git repository: Statamic is a Laravel application, so [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) builds it with its Laravel conventions, installs the Composer packages, builds the Vite assets and serves the site with FrankenPHP in the web process, on the port that Sevalla sets in the `PORT` environment variable. The content, the users and the blueprints are files in the repository, so every deployment and every instance serves the same site, and the site needs no database.

## At a glance

| | |
| - | - |
| Source | A Git repository that holds the site and its content |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (PHP 8.5, Composer, Node.js 24, npm), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | Ingot's `/usr/local/bin/ingot-laravel-start`, which runs `php artisan optimize`, then FrankenPHP serving `public/` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process |
| Services | None |
| Disk | None |
| Health check | `GET /up` returns `200` (readiness probe) |
| Size | **H1** for the web process |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `APP_KEY` | Yes | Runtime | Yes | Generate with `php artisan key:generate --show`, or `echo "base64:$(openssl rand -base64 32)"` |
| `APP_URL` | No | Runtime | No | `https://` and the application's domain, for example `https://storefront-x7k2m.sevalla.app` |
| `SESSION_DRIVER` | Yes | Runtime | No | `cookie` |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

Don't set `APP_ENV` or `APP_DEBUG`: without them, Laravel runs as `production` with debug mode off, and Statamic doesn't watch the content files for changes.

## Before you begin

* A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See [Git providers](https://docs.sevalla.com/git/overview.md).
* PHP 8.5 and Composer 2 on your computer, and Node.js 24 or later with npm. Statamic 6 needs PHP 8.3 or later; this guide pins PHP 8.5.

## Create the project

Create a Statamic site named `storefront` and install its front-end packages:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
composer create-project statamic/statamic storefront --no-interaction
cd storefront
npm install
```

`composer create-project` installs Statamic 6 on Laravel 13 and writes a `.env` file with a new `APP_KEY` for local development. The site starts with one page, **Home**, in `content/collections/pages/home.md`, which reads `Your blank canvas is ready.` Statamic's `.gitignore` already keeps `.env`, `vendor/`, `node_modules/`, the built assets in `public/build` and the control panel's assets in `public/vendor/statamic` out of the repository.

Create the user you sign in to the control panel with. `php please make:user` without arguments asks for each value; with an email address, it creates the user from its options:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
php please make:user admin@shop.example.com --super --password="<a strong password>"
```

Statamic saves the user as a file, `users/admin@shop.example.com.yaml`, with a hash of the password, not the password itself. Commit it with the site: Sevalla serves the users that are in the repository.

## Prepare the project for Sevalla

### Pin the PHP version

Ingot builds on PHP 8.5, its default line, unless the project sets a version. Pin the 8.5 line in `require.php`, so the build doesn't change when a new Ingot release moves its default line:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
composer require "php:~8.5.0"
```

See [Pin the PHP version](https://docs.sevalla.com/guides/php/overview.md#pin-the-php-version).

### Keep the content in Git

Statamic keeps entries, navigation, globals, taxonomies, users and blueprints as files in `content/`, `users/` and `resources/`. Sevalla builds each deployment's image from your repository and starts every instance from that image, so the files in Git are what the site serves, on every instance and after every deployment. Edit the content on your computer and push it:

1. Run the site locally with `php artisan serve`, and sign in to the control panel at `http://localhost:8000/cp`.
2. Edit the content. Statamic saves each change as a file in the project.
3. Commit the files and push. The next deployment serves them.

> [!WARNING]
>
> Changes you save in the control panel of the site on Sevalla are written to the instance's own files. With Ingot's image, the change shows only on that instance and is gone after the next deployment or restart. With the [Dockerfile](#deploy-with-a-dockerfile)'s image, the content folders are read-only, and the save fails.

To let editors work in the control panel of the live site instead, keep the content in a database with Statamic's [Eloquent driver](https://github.com/statamic/eloquent-driver), and the uploaded assets in [object storage](https://docs.sevalla.com/object-storage/overview.md). This guide keeps the content in Git, which needs no database, keeps every change in your Git history, and lets you run more than one instance.

### Keep sessions in a cookie

Statamic's `.env.example` keeps Laravel's sessions in files (`SESSION_DRIVER=file`). On Sevalla, each instance has its own files, and a deployment replaces them, so every deployment would sign everyone out of the control panel, and each instance would keep its own sessions. Set `SESSION_DRIVER` to `cookie` on Sevalla: Laravel then keeps the session in the browser, encrypted with `APP_KEY`, and a sign-in survives deployments and works on every instance.

### What Ingot reads

The project needs no build or start configuration. Ingot reads these files:

| File | Why Sevalla needs it |
| - | - |
| `composer.json` and `composer.lock` | Ingot recognizes Laravel by `laravel/framework`, which Statamic runs on, takes the PHP version from `require.php`, and installs the locked versions with `composer install --no-dev`. Because `intervention/image` is locked, it installs the `gd` extension that Statamic resizes images with. |
| `package.json` and `package-lock.json` | The `build` script, `vite build`, tells Ingot to build the front-end assets. It installs the locked versions with `npm ci`. |
| `bootstrap/app.php` | Registers the health route `/up`, which the web process's readiness probe requests. |
| `content/`, `users/` and `resources/` | The site's content, its users, and its blueprints and templates, which go into the image with the code. |

### How Ingot builds and starts the project

* **Build.** Ingot installs the PHP extensions Laravel uses and `gd`, then runs `composer install --no-dev --optimize-autoloader`. Its Composer scripts run `php artisan statamic:install`, which publishes the control panel's CSS and JavaScript to `public/vendor/statamic`. Then Ingot runs `npm ci` and `npm run build`, which writes the site's CSS and JavaScript to `public/build`.
* **Start.** The web process runs Ingot's Laravel start script. It runs `php artisan optimize` with the deployment's environment variables, then starts FrankenPHP on the port in the `PORT` environment variable (`8080` by default), serving `public/`.
* **HTTPS.** FrankenPHP trusts the `X-Forwarded-Proto` header from Sevalla's proxy, so Statamic sees HTTPS requests as HTTPS: the site and the control panel load their assets from `https://` URLs.
* **Logs.** Ingot sets `LOG_CHANNEL=stderr`, so Laravel and Statamic write their log to the [runtime logs](https://docs.sevalla.com/applications/logs.md).
* **No database.** The Dockerfile that Ingot generates, on the deployment's **Dockerfile** tab, recommends a migration job, because `database/migrations` has Laravel's own migrations. This site keeps its content and users in files and its sessions in a cookie, so it doesn't use a database: skip the job.

Commit everything and push it to your Git provider:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
git init
git add .
git commit -m "Prepare for Sevalla"
git branch -M main
git remote add origin https://github.com/acme/storefront.git
git push -u origin main
```

## Deploy to Sevalla

These steps build with [Ingot](https://docs.sevalla.com/applications/builds/ingot.md). To build from your own Dockerfile instead, see [Deploy with a Dockerfile](#deploy-with-a-dockerfile).

**1. Create the 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**. Keep the **Size** at **H1** or choose a larger one, then click **Create application**. The application opens on its **Overview** tab with the status **Not deployed yet**: creating an application doesn't deploy it.

**2. Add the environment variables**

Click the **Environment variables** tab, then click **Add environment variables**. Add these variables and click **Add environment variables**:

| Variable | Value |
| - | - |
| `APP_KEY` | A new key: run `php artisan key:generate --show` in the project and paste what it prints, which starts with `base64:`. Laravel encrypts the session cookie with it. |
| `APP_URL` | The application's URL: `https://` and the domain under the application's name, for example `https://storefront-x7k2m.sevalla.app`. |
| `SESSION_DRIVER` | `cookie` |

**3. Turn on the readiness probe**

Click the **Processes** tab, click **⋯** on the **Web** process, then click **Update process**. Under **Health checks**, turn on **Readiness probe**, set the **Path** to `/up` and keep the **Port** at the web process's port, `8080` unless you changed it. Click **Update process**. During a deployment, each new instance gets traffic only once `/up` answers. See [Health checks](https://docs.sevalla.com/applications/processes.md#health-checks).

**4. Deploy**

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

**5. Turn on auto-deploy**

To deploy every push to the branch automatically, click the **Settings** tab, then click **Update auto-deploy** in the **Auto-deploy** section. Turn on **Auto-deploy** and click **Update auto-deploy**. Each content change you push then goes live with its own deployment. See [Deployments](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push).

![The Add environment variables sheet with the Availability checkboxes and rows of keys and values](https://assets.sevalla.com/screenshots/light/app-environment-variables-add.png)

## Verify the deployment

1. On the **Deployments** tab, click the deployment. Its **Build log** shows Ingot's [build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan): Laravel and PHP 8.5 from `composer.json`, the extensions and where each comes from, and the Vite build from `package.json`.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Laravel · PHP 8.5 ─────────────────────────────────────────────────────────────────────────────────────╮
   │ PHP                  8.5                                               composer.json require.php "~8.5.0" │
   │ Framework            Laravel                                           composer.json                      │
   │ Extensions           gd                                                intervention/image                 │
   │                      bcmath, intl, pcntl, pdo_mysql, pdo_pgsql, redis  Laravel defaults                   │
   │ Package manager      Composer                                          composer.lock                      │
   │ Document root        /app/public                                       public/index.php                   │
   │ Node.js              24                                                default                            │
   │ JavaScript packages  npm                                               package-lock.json                  │
   │ Assets               npm run build                                     package.json scripts.build         │
   ├─ Image ───────────────────────────────────────────────────────────────────────────────────────────────────┤
   │ Build                dunglas/frankenphp:1-php8.5-trixie                                                   │
   │ Runtime              debian:trixie-slim                                                                   │
   │ User                 1000:1000                                                                            │
   │ Init                 tini                                                                                 │
   │ Port                 $PORT, default 8080                                                                  │
   ╰──────────────────────────────────────────────────────────────────────────────────────────── Ingot v0.5.0 ─╯
   ```

2. Wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**.

3. Click the domain under the application's name, for example `storefront-x7k2m.sevalla.app`. The **Home** page shows `Your blank canvas is ready.`, styled by the CSS that Vite built.

4. Add `/cp` to the address. Statamic's sign-in page opens over HTTPS. Sign in with the email and password of the user in `users/`: the control panel's dashboard opens.

![The application header with its name, Running status, repository, branch and the domain that opens the application](https://assets.sevalla.com/screenshots/light/app-overview-address.png)

If the deployment fails, read its build log, then see [Troubleshoot the deployment](#troubleshoot-the-deployment).

### Check from a terminal

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

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

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

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

2. The health route that the readiness probe requests, with your domain:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -o /dev/null -w '%{http_code}\n' https://storefront-x7k2m.sevalla.app/up
   ```

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

3. The **Home** page, from `content/collections/pages/home.md`:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://storefront-x7k2m.sevalla.app/ | grep -o 'Your blank canvas is ready'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Your blank canvas is ready
   ```

4. The control panel's stylesheet, which `php artisan statamic:install` published during the build, linked over HTTPS:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://storefront-x7k2m.sevalla.app/cp/auth/login | grep -o 'https://[^"]*/app-[^"]*\.css' | head -1
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   https://storefront-x7k2m.sevalla.app/vendor/statamic/cp/build/assets/app-S9lryMwC.css
   ```

5. A sign-in to the control panel. The first request gets the sign-in page and its CSRF token, the second sends the email and the password; `STATAMIC_PASSWORD` is the password of the user in `users/`:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -c cookies.txt https://storefront-x7k2m.sevalla.app/cp/auth/login -o login.html
   curl -s -b cookies.txt -c cookies.txt https://storefront-x7k2m.sevalla.app/cp/auth/login \
     --data-urlencode "_token=$(grep -o '"csrfToken":"[^"]*"' login.html | cut -d'"' -f4)" \
     --data-urlencode "email=admin@shop.example.com" --data-urlencode "password=$STATAMIC_PASSWORD" \
     -o /dev/null -w '%{http_code} %{redirect_url}\n'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   302 https://storefront-x7k2m.sevalla.app/cp
   ```

   A wrong password redirects back to `/cp/auth/login` instead.

## Deploy with a Dockerfile

Ingot needs no Dockerfile. Write your own when you want full control of the image: the base image, the PHP extensions, the system packages and every layer. Add a `Dockerfile`, a `Caddyfile` and a `.dockerignore` to the root of the repository:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# PHP 8.5, the line composer.json requires, with FrankenPHP and the gd extension that Glide resizes images with
FROM dunglas/frankenphp:1-php8.5-trixie AS php
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN install-php-extensions gd \
    && cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
    && printf '%s\n' 'expose_php = Off' 'opcache.validate_timestamps = 0' 'opcache.memory_consumption = 256' \
        'opcache.max_accelerated_files = 20000' 'realpath_cache_size = 4096K' 'realpath_cache_ttl = 600' \
        'upload_max_filesize = 64M' 'post_max_size = 64M' \
        > "$PHP_INI_DIR/conf.d/production.ini" \
    && setcap -r /usr/local/bin/frankenphp \
    && rm -rf /usr/local/bin/php-cgi /usr/local/bin/phpdbg /usr/local/include
# List the Debian packages whose libraries PHP, FrankenPHP and the extensions load, for the final stage
RUN ldd /usr/local/bin/frankenphp /usr/local/bin/php /usr/local/lib/php/extensions/*/*.so \
    | awk '$3 ~ /^\/(usr\/)?lib\// { print $3 }' | sort -u | xargs realpath | xargs dpkg -S \
    | cut -d: -f1 | sort -u > /usr/local/share/php-runtime-packages

FROM php AS build
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
# hadolint ignore=DL3008
RUN apt-get update \
    && apt-get install -y --no-install-recommends unzip \
    && rm -rf /var/lib/apt/lists/*
ENV COMPOSER_ALLOW_SUPERUSER=1
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --no-progress --no-autoloader --no-scripts
COPY . .
RUN composer install --no-dev --no-interaction --no-progress --optimize-autoloader \
    && php artisan optimize --except=config

FROM node:24-trixie-slim AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM debian:trixie-slim
COPY --from=php /usr/local /usr/local
# hadolint ignore=DL3008
RUN apt-get update \
    && xargs -a /usr/local/share/php-runtime-packages \
        apt-get install -y --no-install-recommends ca-certificates media-types tzdata \
    && rm -rf /var/lib/apt/lists/* \
    && ldconfig \
    && useradd --uid 1000 --user-group --no-create-home --home-dir /app app
COPY Caddyfile /etc/frankenphp/Caddyfile
ENV GODEBUG=cgocheck=0 LOG_CHANNEL=stderr PORT=8080 XDG_CONFIG_HOME=/tmp/caddy XDG_DATA_HOME=/tmp/caddy
WORKDIR /app
COPY --from=build /app ./
COPY --from=assets /app/public/build ./public/build
RUN chown -R 1000:1000 storage bootstrap/cache
USER 1000:1000
EXPOSE 8080
CMD ["sh", "-c", "php artisan config:cache && exec frankenphp run --config /etc/frankenphp/Caddyfile"]
```

| Line or choice | Why |
| - | - |
| `dunglas/frankenphp:1-php8.5-trixie` | FrankenPHP's official image with PHP 8.5, the line `require.php` pins, on Debian 13: the image Ingot builds on. A line tag, not a digest, so each build picks up PHP and FrankenPHP security fixes. Its `install-php-extensions` adds `gd`; Statamic needs no other extension that the image lacks. |
| The `php.ini` settings | PHP's production `php.ini`, without the PHP version in response headers, and uploads of up to 64 MB. The code in the image never changes, so OPcache doesn't check the files on every request. |
| `setcap -r` | FrankenPHP's binary carries the capability to bind ports below 1024, which it doesn't need on `8080`. A binary with that capability doesn't start in a container that runs without capabilities. |
| `rm -rf` of `php-cgi`, `phpdbg` and `include` | The CGI binary, the debugger and the C headers, which a FrankenPHP server never uses, stay out of the final image. |
| The `ldd` line | Lists the Debian packages whose libraries PHP, FrankenPHP and the extensions load. The final stage installs exactly those, so adding an extension needs no other change. |
| Four stages | `php` adds the extension and the settings. `build` installs the Composer packages; their scripts publish the control panel's assets to `public/vendor/statamic`. `assets` builds the site's CSS and JavaScript with Node.js 24, the line Ingot uses. The final stage is `debian:trixie-slim` with PHP, FrankenPHP, the site and only the libraries they load, without Composer, Node.js or the compilers the PHP image keeps for building extensions. Its packages aren't pinned to a version, so each build gets Debian's security updates, which is why the lines before the `apt-get` commands tell hadolint to skip its version-pinning check. |
| `composer.json`, `composer.lock`, `package.json` and `package-lock.json` copied before the source | A change to your code or your content reuses the cached `composer install` and `npm ci` layers, so only the steps after them run again. |
| `php artisan optimize --except=config`, then `php artisan config:cache` at start | Routes, views and events don't depend on environment variables, so the build caches them in the image. The configuration does, so the container caches it when it starts, with the deployment's variables, and no secret is written into the image. |
| `USER 1000:1000` | The `app` user the final stage creates, not root. The site's files, its content included, stay owned by root, so the site can read them but not change them; only `storage` and `bootstrap/cache`, where Laravel and Statamic write their caches, belong to user 1000. |
| `exec frankenphp run` | `exec` makes FrankenPHP the container's main process, so it gets Sevalla's `SIGTERM` itself and finishes the requests in progress before it stops. |
| `ENV` | `LOG_CHANNEL=stderr` sends the log to the runtime logs, as with Ingot. FrankenPHP keeps its own files in `/tmp`, which user 1000 can write to, and `GODEBUG=cgocheck=0` is set as FrankenPHP's own image sets it. `EXPOSE 8080` documents Sevalla's default `PORT`, which the `Caddyfile` reads when FrankenPHP starts. |

The `Caddyfile` configures FrankenPHP the way Ingot does:

```text Caddyfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
	admin off
	frankenphp
	servers {
		trusted_proxies static private_ranges
		client_ip_headers CF-Connecting-IP X-Forwarded-For
	}
}

:{$PORT:8080} {
	root * public
	encode zstd br gzip

	@hidden path */.*
	respond @hidden 404

	@scripts {
		path *.php
		not path /index.php
	}
	respond @scripts 404

	@https {
		remote_ip private_ranges
		header X-Forwarded-Proto https
	}
	vars @https https on

	@assets path /build/assets/*
	header @assets Cache-Control "public, max-age=31536000, immutable"

	php_server {
		try_files {path} index.php
		env HTTPS {vars.https}
		env REMOTE_ADDR {client_ip}
	}
}
```

* FrankenPHP listens on the port in `PORT` and serves `public/`. Files that start with a dot get `404`, and so does every PHP file except `index.php`.
* Requests from Sevalla's proxy that arrived over HTTPS reach Statamic with `HTTPS` on, so the site and the control panel use `https://` URLs, and `REMOTE_ADDR` is the visitor's address from the `CF-Connecting-IP` header rather than the proxy's.
* Vite gives built assets a new name when they change, so browsers and the [Sevalla CDN](https://docs.sevalla.com/applications/caching.md) may cache them for a year.

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.env*
node_modules
vendor
public/build
public/hot
public/storage
public/vendor
bootstrap/cache/*.php
storage/framework/cache/data
storage/framework/views/*.php
storage/logs/*.log
storage/statamic
database/*.sqlite
tests
Dockerfile
.dockerignore
```

The `.dockerignore` keeps your local `.env`, installed packages, built and published assets, local caches and tests out of the build, so the image gets what `composer install` and `npm run build` produce from the lock files.

Commit and push the three files. Ingot doesn't build from your `Dockerfile` or use your `Caddyfile`, and it adds the patterns in `.dockerignore` to its own (the build plan shows **Ignored** `17 patterns from .dockerignore`), so you can keep the files in a repository that builds with Ingot. Then switch the application to the Dockerfile build strategy:

**1. Choose the Dockerfile build strategy**

On the application's **Settings** tab, in the **Build strategy** section, click **Update build strategy**. Click **Dockerfile**, keep the **Dockerfile path** `Dockerfile` and the **Docker context** `.`, then click **Update build strategy**.

**2. Deploy**

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

The deployment's build log shows `📦 Building and pushing docker image using Dockerfile...` instead of Ingot's build plan, and its **Dockerfile** tab shows your file. When the deployment's status is **Deployed**, the site, `/up` and the control panel's sign-in answer as in [Check from a terminal](#check-from-a-terminal). Because the content folders belong to root, a save in the control panel fails with `500`, and the runtime logs show `file_put_contents(/app/content/collections/pages/home.md): Failed to open stream: Permission denied`: edit the content in Git, as in [Keep the content in Git](#keep-the-content-in-git). See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md).

## Deploy changes

With **Auto-deploy** on, every push to the branch starts a deployment; otherwise, click **Deploy now** for each change. A content change is a commit like a code change: each deployment builds the image with the content in the repository, then replaces the web process's instances once the new ones answer on `/up`. When you add a package, commit `composer.lock` or `package-lock.json` with it. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

Add configuration on the application's **Environment variables** tab and read it in `config/` files with `env()`, as Laravel's and Statamic's own configuration does. Ingot's start script and the Dockerfile's `php artisan config:cache` cache the configuration when each instance starts, so call `env()` only in `config/` files. Variables apply at the next deployment. To turn the control panel off on Sevalla, set `CP_ENABLED` to `false`, which `config/statamic/cp.php` reads. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

## Add a custom domain

To serve the site from your own domain, such as `shop.example.com`, add the domain on the application's **Domains** tab and create the DNS records that Sevalla shows, then set `APP_URL` to `https://shop.example.com` and deploy. Custom domains need a web process larger than **H1**, so [scale](https://docs.sevalla.com/applications/scaling.md) the web process first. See [Custom domains](https://docs.sevalla.com/domains/overview.md).

## Troubleshoot the deployment

| Symptom | Cause | Fix |
| - | - | - |
| Every page returns status `500`, and the runtime logs show `production.ERROR: No application encryption key has been specified.` | `APP_KEY` isn't set. | Add `APP_KEY` with the output of `php artisan key:generate --show`, then deploy. |
| A change saved in the control panel is gone after a deployment or a restart | The change went to one instance's own files, not to the repository. | Make the change on your computer, commit it and push. See [Keep the content in Git](#keep-the-content-in-git). |
| Saving in the control panel returns `500`, and the runtime logs show `Failed to open stream: Permission denied` | The [Dockerfile](#deploy-with-a-dockerfile)'s image keeps the content folders read-only. | Make the change on your computer, commit it and push. |
| The control panel signs you out after each deployment | `SESSION_DRIVER` isn't `cookie`, so sessions are files on the instance that a deployment replaces. | Set `SESSION_DRIVER` to `cookie`, then deploy. |

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

## Deploy with the API

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

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

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

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

**1. Choose the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns every location your company can use, with its region ID in `location`. Pick one by region ID, here Frankfurt, Germany (`eu-frankfurt-1`); [Locations](https://docs.sevalla.com/platform/locations.md) lists the others. Display names differ between environments, so don't select by them:

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

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

**2. Look up the size**

[List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) gives the ID of **H1**:

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

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

**3. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates it with a web process and doesn't deploy it. [List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns its `sevalla.app` domain as the primary one, for `APP_URL`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(curl -s -X POST "$API/applications" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg cluster "$CLUSTER_ID" --arg repo "$REPO_URL" --arg branch "$BRANCH" \
    '{display_name: "storefront", cluster_id: $cluster, source: "privateGit", git_type: "github",
      repo_url: $repo, default_branch: $branch}')" \
  | jq -r .id)
for i in $(seq 1 30); do
  APP_HOST=$(curl -s "$API/applications/$APP_ID/domains" -H "$H" | jq -r '.data[]? | select(.is_primary) | .name')
  [ -n "$APP_HOST" ] && break
  sleep 2
done
echo "$APP_HOST"
```

Sevalla adds the domain about 2 seconds after it creates the application, so the loop waits for it.

CLI: `sevalla apps create --name storefront --cluster $CLUSTER_ID --source privateGit --git-type github --repository $REPO_URL --branch $BRANCH`, then `sevalla apps domains list --app-id $APP_ID`. MCP: `create_application` with `deploy: false`, then `get_application`.

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

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

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

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

**5. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`) adds one variable per call. The loop adds a new `APP_KEY`, `APP_URL` and `SESSION_DRIVER`, all for runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for pair in "APP_KEY=base64:$(openssl rand -base64 32)" "APP_URL=https://$APP_HOST" "SESSION_DRIVER=cookie"; do
  curl -s -X POST "$API/applications/$APP_ID/env-vars" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --arg key "${pair%%=*}" --arg value "${pair#*=}" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}')" \
    | jq -r .key
done
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_KEY
APP_URL
SESSION_DRIVER
```

`APP_KEY` is 32 random bytes in Base64 with the `base64:` prefix, the format `php artisan key:generate` writes. The build reads none of the variables.

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

**6. Deploy**

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

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

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

**7. Wait for the deployment**

Poll [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until the status is final: `success`, `failed`, `cancelled` or `skipped`. The loop keeps polling through a failed request and stops after 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 "$H" | 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 get $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment`.

**8. 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: the build plan from [Verify the deployment](#verify-the-deployment), the build and the rollout. Without `limit`, it returns only the latest 1,000 lines. When the status is `failed`, the last lines say why; see [Troubleshoot the deployment](#troubleshoot-the-deployment):

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

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

**9. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`), so every content change you push deploys:

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

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

To build from the [Dockerfile](#deploy-with-a-dockerfile) instead of Ingot, send `{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}` with the same call before you deploy. 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 [Check from a terminal](#check-from-a-terminal), with `$APP_HOST` as the domain.


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