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

> Deploy a Symfony 8.1 web application to Sevalla: FrankenPHP serves it, a job runs Doctrine migrations on PostgreSQL, and a worker consumes Messenger's queue.

A Symfony application runs on Sevalla as an application built from your Git repository: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) recognizes Symfony, installs the Composer packages, compiles the AssetMapper assets during the build, and starts FrankenPHP in the web process, which serves `public/` on the port that Sevalla sets in the `PORT` environment variable. A job runs the Doctrine migrations before each deployment, a background worker consumes Messenger's queue, and the data lives in a Sevalla PostgreSQL database.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (PHP 8.5, Composer, AssetMapper), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `frankenphp run --config /etc/frankenphp/Caddyfile --adapter caddyfile`, which Ingot sets, serving `public/` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`php bin/console doctrine:migrations:migrate --no-interaction`, **Before deployment**); `Messenger` background worker (`exec php bin/console messenger:consume async`) |
| Services | PostgreSQL 18 over an internal connection |
| Disk | None |
| Health check | None |
| Size | **H1** for the web process, the job and the worker, **DB1** for the database |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DATABASE_URL` | Yes | Runtime | Yes | Added by the internal connection as `DB_URL` and renamed: `postgres://<user>:<password>@<host>:<port>/<database>` |
| `APP_SECRET` | Yes | Runtime | Yes | Generate with `php -r 'echo bin2hex(random_bytes(16)), PHP_EOL;'` or `openssl rand -hex 16` |
| `DEFAULT_URI` | No | Runtime | No | `https://` and the application's domain, for example `https://storefront-x7k2m.sevalla.app` |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

The internal connection also adds `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME` and `DB_PASSWORD`, which the application doesn't read. `APP_ENV` is `prod` in both images, and the values in `.env` are the defaults for every other variable.

## 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. Symfony 8.1 needs PHP 8.4 or later.
* Docker, to run PostgreSQL on your computer while you generate the first migration.

## Create the project

Create a Symfony 8.1 project from the skeleton, then add the `webapp` pack, which installs Twig, Doctrine, Messenger, AssetMapper, the security component and the mailer:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
composer create-project symfony/skeleton:"8.1.*" storefront
cd storefront
composer config --json extra.symfony.docker false
composer require webapp
```

The `extra.symfony.docker` setting stops Symfony's recipes from adding Docker Compose files and from asking about them, so `composer require` runs without questions.

Add a home page. Create the controller:

```php src/Controller/HomeController.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class HomeController extends AbstractController
{
    #[Route('/', name: 'app_home')]
    public function index(): Response
    {
        return $this->render('home/index.html.twig');
    }
}
```

Then its template, which prints the page's own absolute URL so you can see whether Symfony knows the request came over HTTPS:

```twig templates/home/index.html.twig theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{% extends 'base.html.twig' %}

{% block title %}Storefront{% endblock %}

{% block body %}
    <h1>Hello from Symfony on Sevalla</h1>
    <p>This page's address is {{ url('app_home') }}</p>
{% endblock %}
```

Symfony's `.gitignore` already keeps `vendor/`, `var/`, the compiled assets in `public/assets/` and local `.env.local` files out of the repository.

## Prepare the project for Sevalla

### Pin the PHP version

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

`require.php` pins the PHP line: Ingot builds on PHP 8.5 and follows its patch releases. The skeleton's `>=8.4` would build on Ingot's default line, which a new Ingot release can move. See [Pin the PHP version](https://docs.sevalla.com/guides/php/overview.md#pin-the-php-version).

### Set the PostgreSQL version

Doctrine needs to know the PostgreSQL version to write SQL for it. The connection URL that Sevalla generates doesn't include `serverVersion`, so set the version in `config/packages/doctrine.yaml` by uncommenting `server_version` and setting it to `18`:

```yaml config/packages/doctrine.yaml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
doctrine:
    dbal:
        url: '%env(resolve:DATABASE_URL)%'

        # IMPORTANT: You MUST configure your server version,
        # either here or in the DATABASE_URL env var (see .env file)
        server_version: '18'
```

In `.env`, change `serverVersion=16` to `serverVersion=18` in `DATABASE_URL`, so your local database matches:

```bash .env theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=18&charset=utf8"
```

### Generate the first migration

The webapp pack sends emails and notifications through Messenger's `async` transport, which keeps queued messages in a `messenger_messages` table. `MESSENGER_TRANSPORT_DSN` in `.env` ends in `auto_setup=0`, so Symfony doesn't create the table itself: a migration does. Start PostgreSQL 18 with the credentials in `.env`, wait a few seconds for it to accept connections, then generate the migration:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
docker run -d --name storefront-db -p 5432:5432 -e POSTGRES_USER=app -e POSTGRES_PASSWORD='!ChangeMe!' -e POSTGRES_DB=app postgres:18
php bin/console make:migration
```

The migration in `src/Migrations/` creates `messenger_messages`. Your entities' tables go into the same folder as you add them.

### What Ingot reads

| File | Why Sevalla needs it |
| - | - |
| `composer.json` | `symfony/framework-bundle` tells Ingot that the application is Symfony, and `require.php` sets the PHP line. |
| `composer.lock` | Ingot installs exactly these versions. Because it locks `symfony/asset-mapper`, Ingot also compiles the assets. |
| `public/index.php` | The front controller. FrankenPHP serves `public/` and sends every request without a file of its own to it. |
| `.env` | Defaults for every environment variable. Variables you set on Sevalla win over it. Ingot reads the scheme of `DATABASE_URL` to install the PostgreSQL driver. |
| `src/Migrations/` | The migrations that the migration job runs. |

### How Ingot builds and starts the project

The project needs no build or start configuration. Ingot:

* Sets `APP_ENV=prod` in the image and installs the `intl`, `pcntl`, `pdo_mysql` and `pdo_pgsql` extensions. With `pcntl`, the Messenger worker handles the `SIGTERM` that Sevalla sends when it replaces the worker: it finishes the message it's handling, then stops. Your computer doesn't need `pcntl`.
* Runs `composer install --no-dev --optimize-autoloader`. Its Composer scripts warm the `prod` cache, install the bundles' public files and download the JavaScript packages in `importmap.php`. Then Ingot runs `php bin/console asset-map:compile`, so `public/assets/` is part of the image. None of these steps needs `APP_SECRET` or the database.
* Starts FrankenPHP, which serves `public/` on `PORT` (`8080` by default). Only `index.php` runs: a request for any other `.php` file gets `404`, and files that start with a dot, such as `.env`, are never served.
* Passes HTTPS and the visitor's IP address from Sevalla's proxy to PHP, so Symfony generates `https://` URLs and `$request->getClientIp()` returns the visitor's address without `trusted_proxies` settings.
* Doesn't run migrations or start the Messenger worker: you create a job and a background worker for them.

In `prod`, the Monolog configuration that the webapp pack installs writes logs as JSON to `php://stderr`, which Sevalla shows in the application's [runtime logs](https://docs.sevalla.com/applications/logs.md).

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**, click **PostgreSQL** and choose version 18, the version in `doctrine.yaml`. Enter `storefront-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 `storefront-db` as the **Service** and turn on **Add environment variables to the application**. In the list of variables, rename the key `DB_URL` to `DATABASE_URL`, the variable Doctrine reads, then click **Add internal connection**. 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_SECRET` | A random string. Generate one with `php -r 'echo bin2hex(random_bytes(16)), PHP_EOL;'`. Symfony signs CSRF tokens and other values with it. |
| `DEFAULT_URI` | The application's URL, for example `https://storefront-x7k2m.sevalla.app`. The domain is under the application's name in the page header. Symfony uses it for URLs generated outside a request, such as the links in emails the worker sends. |

**5. Create the migration job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Migrate` as the **Name** and `php bin/console doctrine:migrations:migrate --no-interaction` as the **Custom start command**. Keep the **Start policy** at **Before deployment** and the **Resource** at **H1**, then click **Create process**.

**6. Create the Messenger worker**

Click **Create process** again. Choose **Background worker**, enter `Messenger` as the **Name** and `exec php bin/console messenger:consume async` as the **Custom start command**, keep the **Resource** at **H1**, then click **Create process**. See [Process queued messages](#process-queued-messages).

**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, click the deployment. Its **Build log** shows Ingot's [build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan): Symfony and PHP 8.5 from `composer.json`, the extensions and where each comes from, and AssetMapper from `composer.lock`.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Symfony · PHP 8.5 ───────────────────────────────────────────────────────╮
   │ PHP              8.5                     composer.json require.php "~8.5.0" │
   │ Framework        Symfony                 composer.json                      │
   │ Extensions       pdo_pgsql               .env DATABASE_URL                  │
   │                  intl, pcntl, pdo_mysql  Symfony defaults                   │
   │ Package manager  Composer                composer.lock                      │
   │ Document root    /app/public             public/index.php                   │
   │ Assets           AssetMapper             composer.lock symfony/asset-mapper │
   ├─ 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 the `php bin/console asset-map:compile` step, and, during the rollout, `Migrate has been successful`. If the job fails, the deployment fails and nothing changes for visitors.
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 page shows `Hello from Symfony on Sevalla` and `This page's address is https://storefront-x7k2m.sevalla.app/` on a sky blue background: the URL starts with `https://`, and the background comes from `assets/styles/app.css`, which AssetMapper compiled during the build.

![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 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.
   🚀 Messenger has been deployed successfully.
   ```

3. The worker's start, from [Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`), which searches the last hour by default:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$APP_ID/runtime-logs?search=Consuming%20messages" -H "$H" | jq -r '.[].message'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
    [OK] Consuming messages from transport "async".
   ```

4. The `pcntl` extension that lets the worker stop gracefully, with [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process) (`execProcessCommandV3`) in the web process, whose image the worker shares. `WEB_ID` is the web process from the same sequence; the command runs without a shell, so `grep` runs on your computer:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -X POST "$API/applications/$APP_ID/processes/$WEB_ID/exec" -H "$H" -H "Content-Type: application/json" \
     -d '{"command": ["php", "-m"]}' | jq -r .stdout | grep -x pcntl
   ```

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

5. The home page, with your domain:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://storefront-x7k2m.sevalla.app/ | grep "address is"
   ```

   ```html theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
       <p>This page's address is https://storefront-x7k2m.sevalla.app/</p>
   ```

## Process queued messages

The webapp pack routes emails (`SendEmailMessage`) and notifications (`ChatMessage`, `SmsMessage`) to the `async` transport in `config/packages/messenger.yaml`. When the application sends one, Symfony stores it in the `messenger_messages` table and tells the worker about it with PostgreSQL's `NOTIFY`, and the `Messenger` background worker handles it. To send your own message classes through the worker, route them to `async` in the same file.

The worker's start command is `exec php bin/console messenger:consume async`:

* `exec` replaces the shell that Sevalla starts the command with, so PHP is the process that receives `SIGTERM`. With `pcntl`, which Ingot installs for Symfony and the Dockerfile installs too, Messenger finishes the current message and stops when Sevalla replaces the worker on each deployment.
* The worker runs from the same image as the web process, with the same environment variables, so it reaches the database through `DATABASE_URL`.

On the **Logs** tab, choose the `Messenger` process: when the worker starts, it logs `[OK] Consuming messages from transport "async".` To log each message it handles, add `-vv` to the start command. For more messages at once, increase the worker's instances on the **Processes** tab: each instance takes different messages from the table. See [Background workers](https://docs.sevalla.com/applications/processes.md#background-workers).

![The Create process sheet with Background worker selected, the Name and Custom start command fields, the resource and the scaling settings](https://assets.sevalla.com/screenshots/light/app-processes-worker-create.png)

## 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 extensions the application needs
FROM dunglas/frankenphp:1-php8.5-trixie AS php
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN install-php-extensions intl pcntl 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
# 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 APP_ENV=prod COMPOSER_ALLOW_SUPERUSER=1
WORKDIR /app
COPY composer.json composer.lock symfony.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 bin/console asset-map:compile \
    && chown -R 1000:1000 var

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
ENV APP_ENV=prod GODEBUG=cgocheck=0 PORT=8080 XDG_CONFIG_HOME=/tmp/caddy XDG_DATA_HOME=/tmp/caddy
COPY Caddyfile /etc/frankenphp/Caddyfile
WORKDIR /app
COPY --from=build /app /app
USER 1000:1000
EXPOSE 8080
CMD ["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 `intl` for Symfony, `pcntl` for the worker and `pdo_pgsql` for Doctrine. Add any other extension your `composer.json` requires. |
| 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 are those of Symfony's performance guide. |
| `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. |
| `composer.json`, `composer.lock` and `symfony.lock` copied before the source | The first `composer install` depends only on them, so a change to your code reuses its cached layer. The second writes the optimized autoloader and runs Symfony's scripts, which warm the `prod` cache and download the JavaScript packages, then `asset-map:compile` builds `public/assets/`. No step reads a secret or the database. |
| Final stage `debian:trixie-slim` | Debian 13 with PHP, FrankenPHP, the application and only the libraries they load, without Composer or the compilers that the PHP image keeps for building extensions. The 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. |
| `USER 1000:1000` | The application doesn't run as root. Its files stay owned by root, so it can read its code but not change it; only `var/`, where Symfony writes its cache, belongs to user 1000. |
| `CMD ["frankenphp", "run", ...]` | FrankenPHP is the container's main process, so it gets Sevalla's `SIGTERM` itself and finishes the requests in progress before it stops. |
| `ENV` | `APP_ENV=prod` for the web process, the worker and the job. 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`. |

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

	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 Symfony with `HTTPS` on, so it generates `https://` URLs, and `REMOTE_ADDR` is the visitor's address from the `CF-Connecting-IP` header rather than the proxy's. Symfony needs no `trusted_proxies` settings.

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.env.*
var
vendor
public/assets
public/bundles
assets/vendor
tests
.phpunit.cache
Dockerfile
.dockerignore
```

The `.dockerignore` keeps installed packages, compiled assets, caches, tests and the local `.env.*` files out of the build. `.env` stays: it holds the defaults Symfony reads at runtime and no secrets.

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** `11 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 domain shows the same page as in [Verify the deployment](#verify-the-deployment), with an `https://` address. The `Migrate` job, the `Messenger` worker 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 and the worker's instances. For a schema change, run `php bin/console make:migration` locally and commit the new migration with the code. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

Symfony reads real environment variables before the values in `.env`, so a variable you add on the application's **Environment variables** tab replaces the default in `.env`. Changes apply at the next deployment. Keep secrets, such as `APP_SECRET` and a `MAILER_DSN` with a password, on Sevalla rather than in a committed `.env` file. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

## Add a custom domain

[Add the domain](https://docs.sevalla.com/domains/overview.md) on the application's **Domains** tab, then set `DEFAULT_URI` to it, for example `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.

## Troubleshoot the deployment

| Symptom | Cause | Fix |
| - | - | - |
| The deployment fails at `Migrate has failed`, and the job's output shows `connection to server at "127.0.0.1", port 5432 failed: Connection refused` | `DATABASE_URL` isn't set, so Doctrine uses the local default from `.env`. | On the **Environment variables** tab, check that `DATABASE_URL` exists. If the internal connection added `DB_URL` instead, add `DATABASE_URL` with the same value, then deploy. |
| Messages wait in `messenger_messages` and nothing handles them | The `Messenger` worker doesn't exist, or it stopped. | Create the background worker, or read its runtime logs on the **Logs** tab. |
| A page returns `500` and the runtime logs show `Uncaught PHP Exception` | The application threw an exception. The JSON log line names it and the file and line it came from. | Fix the cause in your code and 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 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**

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

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

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

**3. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) with PostgreSQL 18, the version in `doctrine.yaml`. 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 password "$DB_PASSWORD" \
    '{display_name: "storefront-db", type: "postgresql", version: "18", 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 storefront-db --type postgresql --db-version 18 --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: "DATABASE_URL"`: the tool connects the database and stores its URL, in the form `postgresql://<user>:<password>@<host>:<port>/<database>`, which Doctrine accepts like `postgres://`. 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 `DEFAULT_URI`:

```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, and `DEFAULT_URI` would be `https://` without a domain.

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

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

```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}')" | jq -r .resource_type_id
```

CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID`. MCP: `scale_process` with `size_id` and `apply_now: false`.

**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), with `DB_URL` renamed to `DATABASE_URL`, then a new `APP_SECRET` and `DEFAULT_URI`, 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)
for pair in "DB_HOST=$DB_HOST" "DB_PORT=$DB_PORT" "DB_DATABASE=storefront" "DB_USERNAME=storefront" \
  "DB_PASSWORD=$DB_PASSWORD" "DATABASE_URL=postgres://storefront:$DB_PASSWORD@$DB_HOST:$DB_PORT/storefront" \
  "APP_SECRET=$(openssl rand -hex 16)" "DEFAULT_URI=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. The build reads none of them: Symfony's Composer scripts and `asset-map:compile` use the defaults in `.env`.

CLI: `sevalla apps env-vars create $APP_ID --key DEFAULT_URI --value https://$APP_HOST`, 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 job and the worker**

[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 bin/console doctrine:migrations:migrate --no-interaction",
      resource_type_id: $size, job_start_policy: "beforeDeployment", scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
  | jq -c '{display_name, type}'
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: "Messenger", type: "worker", entrypoint: "exec php bin/console messenger:consume async",
      resource_type_id: $size, scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
  | jq -c '{display_name, type}'
```

CLI: `sevalla apps processes create $APP_ID --name Migrate --type job --resource-type $H1_ID --command "php bin/console doctrine:migrations:migrate --no-interaction" --job-start-policy beforeDeployment`, and `sevalla apps processes create $APP_ID --name Messenger --type worker --resource-type $H1_ID --command "exec php bin/console messenger:consume async"`. MCP: `sevalla_api_create` with `createProcessV3` and the same bodies.

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