> ## 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 Laravel application

> Deploy a Laravel 13 application to Sevalla from a Git repository: Ingot builds its Vite assets and serves it with FrankenPHP, and a job migrates PostgreSQL.

A Laravel application runs on Sevalla as an application built from your Git repository: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) installs its Composer packages, builds its Vite assets during the build and serves it with FrankenPHP in the web process, and a job runs the migrations before each deployment. Its data, sessions and cache live in a Sevalla PostgreSQL database that the application reaches over an internal connection.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| 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; `Migrate` job (`php artisan migrate --force`, **Before deployment**) |
| Services | PostgreSQL 18 over an internal connection |
| Disk | None |
| Health check | `GET /up` returns `200` (readiness probe) |
| Size | **H1** for the web process and the job, **DB1** for the database |

### 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)"` |
| `DB_CONNECTION` | Yes | Runtime | No | `pgsql` |
| `APP_URL` | No | Runtime | No | `https://` and the application's domain, for example `https://storefront-x7k2m.sevalla.app` |
| `DB_HOST` | Yes | Runtime | No | Added by the internal connection: the database's internal hostname |
| `DB_PORT` | Yes | Runtime | No | Added by the internal connection: `5432` |
| `DB_DATABASE` | Yes | Runtime | No | Added by the internal connection: the database name |
| `DB_USERNAME` | Yes | Runtime | No | Added by the internal connection: the database user |
| `DB_PASSWORD` | Yes | Runtime | Yes | Added by the internal connection: the database password |
| `DB_URL` | No | Runtime | Yes | Added by the internal connection: `postgres://<user>:<password>@<host>:<port>/<database>` |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

## 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 for the front-end build. Laravel 13 needs PHP 8.3 or later; this guide pins PHP 8.5.

## Create the project

Create a Laravel project named `storefront` and install its front-end packages:

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

`composer create-project` installs Laravel 13, writes a `.env` file with a new `APP_KEY` for local development, and migrates a local SQLite database. `npm install` installs Vite and Tailwind CSS and writes `package-lock.json`. Laravel's `.gitignore` already keeps `.env`, `vendor/`, `node_modules/` and the built assets in `public/build` out of the repository. Sevalla doesn't use `.env`: you set the production values as environment variables.

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

### 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`, takes the PHP version from `require.php`, and installs the locked versions with `composer install --no-dev`. |
| `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. |
| `database/migrations/` | The tables for users, sessions, cache and queued jobs, which the migration job creates. |

### Choose the database

Laravel 13 keeps sessions, the cache and queued jobs in the database by default (`SESSION_DRIVER`, `CACHE_STORE` and `QUEUE_CONNECTION` are `database`), and its migrations create their tables, so one database serves the whole application and every instance of the web process shares the same sessions. This guide uses PostgreSQL because Laravel runs each migration on PostgreSQL in a transaction, so a migration that fails in the job leaves the schema as it was, while MySQL commits each schema change as it runs it.

### How Ingot builds and starts the project

* **Build.** Ingot installs the PHP extensions Laravel uses (`bcmath`, `intl`, `pcntl`, `pdo_mysql`, `pdo_pgsql` and `redis`), runs `composer install --no-dev --optimize-autoloader` and `php artisan storage:link`, then `npm ci` and `npm run build`, which writes the Vite assets to `public/build`.
* **Start.** The web process runs Ingot's Laravel start script. It runs `php artisan optimize` with the deployment's environment variables, so the cached configuration never holds values from the build, 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 Laravel sees HTTPS requests as HTTPS and generates `https://` URLs for assets and redirects without trusted proxy settings.
* **Logs.** Ingot sets `LOG_CHANNEL=stderr`, so Laravel writes its log to the [runtime logs](https://docs.sevalla.com/applications/logs.md) instead of `storage/logs`, which resets on every deployment.
* **Migrations.** Ingot doesn't run them. A [job](https://docs.sevalla.com/applications/processes.md#jobs) runs them once per deployment, before the new version starts.

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

In the sidebar, click **Databases**, then click **Create database**. Under **Database**, choose **PostgreSQL** and the newest version. Enter `orders-db` as the **Display name**, choose a **Data center** and a **Size**, then click **Create database**. See [Create a database](https://docs.sevalla.com/databases/create.md) for every field.

**2. Create the application**

In the sidebar, click **Applications**, then click **Create** > **Application**. Under **Source**, click **Private repository**, then choose the **Git provider**, the **Repository** and the **Branch**. Under **Location**, choose the same **Data center** as the database. Keep the **Size** at **H1** or choose a larger one, then click **Create application**. The application opens on its **Overview** tab with the status **Not deployed yet**: creating an application doesn't deploy it.

**3. Connect the database**

Click the **Networking** tab. Under **Connected services**, click **Add internal connection**. Choose `orders-db` as the **Service**, turn on **Add environment variables to the application**, and click **Add internal connection**. Sevalla adds `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` and `DB_URL`, the names Laravel's `config/database.php` reads. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

**4. 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:`. |
| `APP_URL` | The application's URL: `https://` and the domain under the application's name, for example `https://storefront-x7k2m.sevalla.app`. Laravel uses it for URLs it builds outside a request, such as links in queued mail. |
| `DB_CONNECTION` | `pgsql` |

Don't set `APP_ENV` or `APP_DEBUG`: without them, Laravel runs as `production` with debug mode off.

**5. Create the migration job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Migrate` as the **Name** and `php artisan migrate --force` as the **Custom start command**. Keep the **Start policy** at **Before deployment** and the **Resource** at **H1**, then click **Create process**. `--force` confirms the migration, which Laravel otherwise asks for in production.

**6. Turn on the readiness probe**

On the **Processes** tab, click **⋯** on the **Web** process, then click **Update process**. Under **Health checks**, turn on **Readiness probe**, set the **Path** to `/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).

**7. Deploy**

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

**8. Turn on auto-deploy**

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

![The Create process sheet with Job selected, the Name and Custom start command fields, the Start policy set to Before deployment, and the H1 resource](https://assets.sevalla.com/screenshots/light/app-processes-job-create.png)

## Verify the deployment

1. On the **Deployments** tab, wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**. Open the deployment to read its build log. Ingot's build plan comes first:

   ```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           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.4.1 ─╯
   ```

   Later, the build log shows `vite build` writing the assets to `public/build/assets`, and, during the rollout, `Migrate has been successful`. If the job fails, the deployment fails and nothing changes for visitors. See [Read the build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan).
2. Click the domain under the application's name, for example `storefront-x7k2m.sevalla.app`. Laravel's welcome page opens with `Let's get started` and the Laravel version, styled by the CSS that Vite built. Its stylesheet link uses `https://`:

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

   ```html theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   <link rel="stylesheet" href="https://storefront-x7k2m.sevalla.app/build/assets/app-6jSCucsl.css" />
   ```

   The page also starts a session, which Laravel saves in the `sessions` table: it works only when the database connection and the migrations do.
3. Add `/up` to the address. The page reads `Application up`: it's what the readiness probe sees.

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

### Check from a terminal

These checks need no dashboard. The 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 rollout's results in the build log, from [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`). The messages carry terminal color codes, which `gsub` removes, and `select` drops the lines of the image build, which start with `#`:

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

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ✅ Migrate has been successful
   🚀 Web has been deployed successfully.
   ```

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

4. The stylesheet link, as in [Verify the deployment](#verify-the-deployment), which shows that the Vite assets, the database session and HTTPS work:

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

   ```html theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   <link rel="stylesheet" href="https://storefront-x7k2m.sevalla.app/build/assets/app-6jSCucsl.css" />
   ```

## Read the logs

Laravel writes to the log channel in `LOG_CHANNEL`, which Ingot sets to `stderr`, so every log call and every error the application reports appears on the application's **Logs** tab. For example, `Log::info('Order 42 confirmed')` appears as:

```text Runtime logs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
[2026-10-29 18:21:37] production.INFO: Order 42 confirmed
```

The web process's runtime logs also show each start: `php artisan optimize` caching the configuration, routes, events and views, then `FrankenPHP started`. Laravel logs every level from `debug` up by default; set the `LOG_LEVEL` environment variable to `info` or `warning` to log less. See [Logs](https://docs.sevalla.com/applications/logs.md).

## 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"}}
FROM dunglas/frankenphp:1-php8.5-trixie AS php
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN install-php-extensions pdo_pgsql \
    && 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' \
        > "$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
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
# hadolint ignore=DL3008
RUN apt-get update \
    && apt-get install -y --no-install-recommends unzip \
    && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
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 . .
COPY --from=build /app/vendor ./vendor
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 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 `pdo_pgsql` for PostgreSQL; add any other extension the application needs to that line. |
| The `php.ini` settings | PHP's production `php.ini`, without the PHP version in response headers. The code in the image never changes, so OPcache doesn't check the files on every request, and the cache sizes fit a framework's thousands of files, as in Ingot's image. |
| `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 extensions and the settings. `build` installs the Composer packages. `assets` builds the Vite assets with Node.js 24, the line Ingot uses; Tailwind CSS reads Laravel's views in `vendor/`, so the stage copies it from `build`. The final stage is `debian:trixie-slim` with PHP, FrankenPHP, the application 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 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 application's files stay owned by root, so it can read its code but not change it; only `storage` and `bootstrap/cache`, where Laravel writes, 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 Laravel's 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 Laravel with `HTTPS` on, so it generates `https://` URLs, and `$request->ip()` returns the visitor's address from the `CF-Connecting-IP` header rather than the proxy's. Laravel needs no trusted proxy settings.
* 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
bootstrap/cache/*.php
storage/framework/views/*.php
storage/logs/*.log
database/*.sqlite
tests
Dockerfile
.dockerignore
```

The `.dockerignore` keeps your local `.env`, installed packages, built assets, 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** `14 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, then `Migrate has been successful`, and its **Dockerfile** tab shows your file. When the deployment's status is **Deployed**, the welcome page and `/up` answer as in [Verify the deployment](#verify-the-deployment), with `https://` asset URLs. The `Migrate` job, the readiness probe and the environment variables work unchanged. 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. Each deployment builds the image, runs the `Migrate` job, then replaces the web process's instances once the new ones answer on `/up`. For a schema change, create a migration with `php artisan make:migration` and commit it with the code. 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 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 and use `config()` everywhere else, as Laravel recommends for a cached configuration. Variables apply at the next deployment. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

## Add a custom domain

To serve the application 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 |
| - | - | - |
| The `Migrate` job fails with `Database file at path [/app/database/database.sqlite] does not exist` | `DB_CONNECTION` isn't set, so Laravel uses its default, SQLite. | Add `DB_CONNECTION` with the value `pgsql`, then deploy. |
| 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. |
| The `Migrate` job fails, and its runtime logs show `In Request.php line 395:` and `Invalid URI.` | `APP_URL` has no domain, for example `https://`. | Set `APP_URL` to `https://` and the application's domain, then deploy. |
| The page has no styles, and the browser reports mixed content | Laravel generates `http://` asset URLs, because your own image serves the application without trusting Sevalla's proxy. | Use the `Caddyfile` from [Deploy with a Dockerfile](#deploy-with-a-dockerfile), or Ingot. |

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). To add queues, the scheduler, uploads or Redis caching, see the [Laravel guides](https://docs.sevalla.com/guides/php/laravel/overview.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 and databases, 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 sizes and the PostgreSQL version**

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

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

CLI: `sevalla resources db-types`, `sevalla resources process-types` and `sevalla resources db-versions --cluster $CLUSTER_ID`. MCP: `get_platform_options` for the sizes, and `sevalla_api_read` with `listDatabaseVersionsV3` for the version.

**3. Create the 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 in a variable for the environment variables:

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

The database starts as `creating`. Poll [Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) until it's `ready`. On `error`, delete the database and create it again:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 60); do
  DB_STATUS=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r '.status // empty')
  case $DB_STATUS in ready|error) break ;; esac
  sleep 10
done
echo "$DB_STATUS"
```

CLI: `sevalla databases create --name orders-db --type postgresql --db-version $PG_VERSION --cluster $CLUSTER_ID --resource-type $DB1_ID --db-name storefront --db-user storefront --db-password $DB_PASSWORD`. MCP: `create_database` generates a password that it never returns. Create the application first, then pass `connect_to_application_id` and `env_var_name: "DB_URL"`: the tool connects the database and stores its URL, in the form `postgresql://<user>:<password>@<host>:<port>/<database>`, in `DB_URL`, which Laravel reads in place of the other `DB_*` variables. Then skip the connection step, and leave the connection's variables out of the environment variables step.

**4. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates it with a web process and doesn't deploy it. [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. Without the wait, the variable can be empty: `APP_URL` is then `https://`, and the `Migrate` job fails with `Invalid URI.`

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

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

The API creates the web process at **S1**, not the dashboard's **H1**. [List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) finds it, and [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets **H1** and the readiness probe 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.

**6. Connect the database**

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

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

It prints the connection's ID. If it prints nothing or `null`, the connection wasn't created: run the call again, because without it the application can't reach the database. Unlike the dashboard, the API doesn't add the connection's environment variables. The next step adds them.

CLI: `sevalla apps internal-connections create $APP_ID --target-id $DB_ID --target-type database`. MCP: `sevalla_api_create` with `createApplicationInternalConnectionV3` and the same body.

**7. Add the environment variables**

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

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

It prints each key as it's added. `APP_KEY` is 32 random bytes in Base64 with the `base64:` prefix, the format `php artisan key:generate` writes.

CLI: `sevalla apps env-vars create $APP_ID --key DB_CONNECTION --value pgsql`, 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.

**8. Create the migration job**

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "$API/applications/$APP_ID/processes" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg size "$H1_ID" \
    '{display_name: "Migrate", type: "job", entrypoint: "php artisan migrate --force", resource_type_id: $size,
      job_start_policy: "beforeDeployment", scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
  | jq -c '{display_name, job_start_policy}'
```

CLI: `sevalla apps processes create $APP_ID --name Migrate --type job --resource-type $H1_ID --command "php artisan migrate --force" --job-start-policy beforeDeployment`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**9. Deploy**

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

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

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

**10. Wait for the deployment**

Poll [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until the status is 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"
```

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

**11. Turn on auto-deploy**

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

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

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 `https://$APP_HOST` as the domain.


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