> ## 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 Drupal site

> Deploy Drupal 11 to Sevalla from a Composer project: PostgreSQL, settings.php from environment variables, a Drush job that installs and updates the site, and files on a disk.

Drupal runs on Sevalla as an application built from a Composer project in your Git repository: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) installs the Composer packages, Drupal core and Drush among them, and the PHP extensions Drupal needs, and the web process starts FrankenPHP with a `Caddyfile` from the repository that serves Drupal's `web/` folder. A job runs Drush before each deployment: it installs Drupal into an empty database, then applies database updates. The content lives in a Sevalla PostgreSQL database and uploaded files on a disk.

## At a glance

| | |
| - | - |
| Source | A Git repository with a `drupal/recommended-project` Composer project |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (PHP 8.5, Composer), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `frankenphp run --config Caddyfile`, from the `Procfile`, serving `web/` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Drush` job (`sh scripts/deploy.sh`, **Before deployment**) |
| Services | PostgreSQL 18 over an internal connection |
| Disk | 10 GB on the web process at `/app/web/sites/default/files`, which limits the web process to 1 instance |
| Health check | None |
| Size | **H1** for the web process and the job, **DB1** for the database |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `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>`. `settings.php` doesn't read it. |
| `DRUPAL_HASH_SALT` | Yes | Runtime | Yes | Generate with `openssl rand -hex 32` |
| `DRUPAL_ADMIN_PASSWORD` | Yes, for the first deployment | Runtime | Yes | The password of the `admin` account that the first deployment creates. Generate with `openssl rand -hex 16`, or choose one. |
| `DRUSH_OPTIONS_URI` | Yes | 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 |

## 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 with the `gd` and `pdo_pgsql` extensions, and Composer 2, on your computer. Drupal 11 needs PHP 8.3 or later. You don't need a database on your computer: the first deployment installs Drupal on Sevalla.

## Create the project

Create a Drupal 11 project from Drupal's recommended project template, then add Drush, Drupal's command-line tool:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
composer create-project drupal/recommended-project:^11 storefront --no-interaction
cd storefront
composer require drush/drush
```

The template installs Drupal core into `web/core` and puts the files that Drupal serves in `web/`: `index.php`, `robots.txt` and `sites/`. Composer's `vendor/` folder and the project's `composer.json` stay outside `web/`, where no request reaches them. This guide was tested with Drupal 11.4.11 and Drush 13.8.0.

The template has no `.gitignore`. Create one that keeps what Composer installs and the uploaded files out of the repository:

```text .gitignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
/vendor/
/web/core/
/web/libraries/
/web/modules/contrib/
/web/profiles/contrib/
/web/themes/contrib/
/web/sites/*/files/
/web/sites/*/settings.local.php
```

Commit everything else, the files that Composer copied into `web/` included: `web/index.php` is Drupal's front controller, which FrankenPHP runs for every page.

## Prepare the project for Sevalla

### Pin the PHP version and the PostgreSQL driver

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

* `require.php` pins the PHP line: Ingot builds on PHP 8.5 and follows its patch releases. See [Pin the PHP version](https://docs.sevalla.com/guides/php/overview.md#pin-the-php-version).
* Drupal connects to PostgreSQL through PHP's `pdo_pgsql` extension, which Drupal core doesn't require in its `composer.json`. Requiring it in yours makes Ingot install it, next to the `gd` extension that Drupal core requires.

### Read the settings from environment variables

Create `web/sites/default/settings.php`. It reads the database connection, the hash salt and the site's address from environment variables, so no secret is in the repository:

```php web/sites/default/settings.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<?php

// The database: the variables Sevalla adds when you connect the database
$databases['default']['default'] = [
  'driver' => 'pgsql',
  'namespace' => 'Drupal\pgsql\Driver\Database\pgsql',
  'autoload' => 'core/modules/pgsql/src/Driver/Database/pgsql/',
  'host' => getenv('DB_HOST'),
  'port' => getenv('DB_PORT'),
  'database' => getenv('DB_DATABASE'),
  'username' => getenv('DB_USERNAME'),
  'password' => getenv('DB_PASSWORD'),
  'prefix' => '',
];

// Signs one-time sign-in links and form tokens
$settings['hash_salt'] = getenv('DRUPAL_HASH_SALT');

// Answer only requests for the site's own domain, from its address
if ($uri = getenv('DRUSH_OPTIONS_URI')) {
  $settings['trusted_host_patterns'] = ['^' . preg_quote(parse_url($uri, PHP_URL_HOST)) . '$'];
}

// Exported configuration, outside the web root
$settings['config_sync_directory'] = '../config/sync';
```

* `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME` and `DB_PASSWORD` are the variables Sevalla adds to the application when you connect the database.
* `hash_salt` is a random string that Drupal mixes into one-time sign-in links and form tokens. Without it, Drupal treats the site as not installed.
* `DRUSH_OPTIONS_URI` is the site's address. Drush reads it for the links it prints, and `trusted_host_patterns` allows requests for its host name only, which protects the site from requests with a forged `Host` header.
* `config_sync_directory` is where Drupal exports its configuration, in `config/sync` next to `web/`. Create the folder with an empty `config/sync/.gitkeep` file, so it's in the repository.

### Serve the web folder with FrankenPHP

Ingot serves `index.php` from `public/`, `webroot/` or the root of the repository. Drupal's is in `web/`, so without a start command, the build fails with `No index.php found`. Add a `Caddyfile` that serves `web/` the way Ingot's configuration serves `public/`, with the rules that Drupal's `.htaccess` files apply on Apache, which FrankenPHP doesn't read:

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

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

	@hidden {
		path */.*
		not path /.well-known/*
	}
	respond @hidden 404

	@code path_regexp (?i)\.(engine|inc|install|make|module|profile|po|sh|sql|theme|twig|tpl|xtmpl|yml)$
	respond @code 404

	@scripts {
		path_regexp (?i)\.php(/|$)
		not path /index.php /index.php/*
	}
	respond @scripts 404

	@forwarded_https {
		remote_ip private_ranges
		header X-Forwarded-Proto https
	}
	vars @forwarded_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 `web/`. A request for a path without a file of its own, such as `/node/1`, goes to `index.php`.
* `index.php` is the only PHP file that runs. A PHP file anywhere else, such as one in the uploads folder, `/core/install.php` or `/update.php`, gets `404`. Drush installs and updates the site instead.
* Files that start with a dot, such as `.htaccess`, and Drupal's code and configuration files, such as `.yml`, `.twig` and `.module` files, get `404`.
* Requests from Sevalla's proxy that arrived over HTTPS reach Drupal with `HTTPS` on, so Drupal builds `https://` links and marks its session cookie secure, and `REMOTE_ADDR` is the visitor's address from the `CF-Connecting-IP` header rather than the proxy's. Drupal needs no `reverse_proxy` settings.

Then add a `Procfile` that tells Ingot to start FrankenPHP with it:

```text Procfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
web: frankenphp run --config Caddyfile
```

### Install and update the site with Drush

Add the script that the `Drush` job runs before each deployment:

```sh scripts/deploy.sh theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
#!/bin/sh
# Runs before each deployment: installs Drupal into an empty database, then applies database updates.
set -e

if ! vendor/bin/drush status --field=db-status | grep -q Connected; then
  vendor/bin/drush site:install standard --yes --site-name=Storefront --account-name=admin \
    --account-pass="$DRUPAL_ADMIN_PASSWORD"
fi
vendor/bin/drush updatedb --yes
vendor/bin/drush cache:rebuild
```

* On the first deployment, the database is empty, so `drush status` doesn't report `Connected`, and `drush site:install` installs Drupal with the standard profile and an `admin` account whose password is `DRUPAL_ADMIN_PASSWORD`. Drush doesn't print the password when you set it.
* On every deployment, `drush updatedb` applies the database updates of a new Drupal core or module release, and `drush cache:rebuild` clears Drupal's caches, which live in the database, for the new code.
* The image has no PostgreSQL client, so `drush site:install` can't drop the tables of a site that's already installed. If the job ever reached it on an installed site, it would stop with an error that starts `Drush was unable to drop all tables` and ends `Drupal threw an AlreadyInstalledException.`, the deployment would fail, and the running site would stay as it is.

### What Ingot reads

| File | Why Sevalla needs it |
| - | - |
| `composer.json` | Tells Ingot that the application is PHP. `require.php` sets the PHP line, and `ext-pdo_pgsql` adds the PostgreSQL driver. |
| `composer.lock` | Ingot installs exactly these versions, with Drupal's Composer plugins, which put core in `web/core`. Drupal core requires `ext-gd`, so Ingot installs `gd` too. |
| `Procfile` | The web process's start command: FrankenPHP with your `Caddyfile`. |
| `Caddyfile` | How FrankenPHP serves `web/`. |
| `web/sites/default/settings.php` | Reads the settings from environment variables. |
| `scripts/deploy.sh` | What the `Drush` job runs. |

### How Ingot builds and starts Drupal

* Runs `composer install --no-dev --optimize-autoloader`, which installs Drupal core, Drush and their packages and copies Drupal's scaffold files into `web/`. It installs the `gd` and `pdo_pgsql` extensions.
* Starts the command from the `Procfile`. The build plan's **Document root** row shows `/app`, the root of Ingot's own configuration, which the `Procfile` replaces: your `Caddyfile` serves `/app/web`.
* Runs the web process and the job as user 1000, which can write to the application's folder. What Drupal writes there lasts until the next deployment and stays on the instance that wrote it, except in `web/sites/default/files`, where the disk keeps it.
* Doesn't install Drupal or run database updates: the `Drush` job does.

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. 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**, 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` to the application. 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 |
| - | - |
| `DRUPAL_HASH_SALT` | A random string. Generate one with `openssl rand -hex 32`. |
| `DRUPAL_ADMIN_PASSWORD` | The password for Drupal's `admin` account, which the first deployment creates. Generate one with `openssl rand -hex 16` and keep it. |
| `DRUSH_OPTIONS_URI` | The application's URL, for example `https://storefront-x7k2m.sevalla.app`. The domain is under the application's name in the page header. |

**5. Create the Drush job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Drush` as the **Name** and `sh scripts/deploy.sh` as the **Custom start command**. Keep the **Start policy** at **Before deployment** and the **Resource** at **H1**, then click **Create process**.

**6. Create the disk**

Click the **Disks** tab, then click **Create disk**. Choose the **Web** process, enter `/app/web/sites/default/files` as the **Path**, choose a **Size**, and click **Create disk**. See [Keep uploaded files on a disk](#keep-uploaded-files-on-a-disk).

**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): PHP 8.5 from `composer.json`, the `pdo_pgsql` and `gd` extensions and where each comes from, and the start command from the `Procfile`.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ PHP 8.5 application ────────────────────────────────────────────────────────────────╮
   │ PHP              8.5                                composer.json require.php "~8.5.0" │
   │ Extensions       pdo_pgsql                          composer.json                      │
   │                  gd                                 composer.lock                      │
   │ Package manager  Composer                           composer.lock                      │
   │ Document root    /app                               default                            │
   │ Start            frankenphp run --config Caddyfile  Procfile                           │
   ├─ 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, during the rollout, the build log shows `Drush has been successful`. On the first deployment, the job installs Drupal, which takes about 40 seconds. 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`. Drupal's front page opens with the title **Welcome!** in the Olivero theme. Add `/user/login` to the address, sign in as `admin` with the password in `DRUPAL_ADMIN_PASSWORD`, and the administration toolbar appears.

![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`, `WEB_ID` and `DEPLOYMENT_ID` as set in [Deploy with the API](#deploy-with-the-api), and `DRUPAL_ADMIN_PASSWORD` from the same sequence.

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"}}
   ✅ Drush has been successful
   🚀 Web has been deployed successfully.
   ```

3. The installation, in the job's output, 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=Installation%20complete" -H "$H" | jq -r '.[].message'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
    [success] Installation complete. (Admin)
   ```

4. The disk on the web process, from [Get process](https://api-docs.sevalla.com/v3/applications/get-process) (`getProcessV3`):

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$APP_ID/processes/$WEB_ID" -H "$H" | jq -c '{disk: .disk | {size_gib, mount_path}, scaling_strategy}'
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"disk":{"size_gib":10,"mount_path":"/app/web/sites/default/files"},"scaling_strategy":{"type":"manual","config":{"instanceCount":1}}}
   ```

5. Drupal's status report, 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. `--severity=2` lists only errors, so an empty list means the report has none: the database, the files folder, the configuration folder and the trusted host settings are in order:

   ```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": ["vendor/bin/drush", "core:requirements", "--severity=2", "--format=json"]}' | jq -j .stdout
   ```

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

6. The front page's title and its RSS link, which Drupal builds with `https://` because FrankenPHP passes HTTPS on:

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

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   https://storefront-x7k2m.sevalla.app/rss.xml
   <title>Welcome! | Storefront</title>
   ```

7. A sign-in as `admin`. The first request gets the sign-in form and its `form_build_id`, the second sends the form:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -c cookies.txt https://storefront-x7k2m.sevalla.app/user/login -o login.html
   curl -s -b cookies.txt -c cookies.txt https://storefront-x7k2m.sevalla.app/user/login \
     --data-urlencode "name=admin" --data-urlencode "pass=$DRUPAL_ADMIN_PASSWORD" \
     --data-urlencode "form_id=user_login_form" --data-urlencode "op=Log in" \
     --data-urlencode "form_build_id=$(grep -o 'name="form_build_id" value="[^"]*"' login.html | cut -d'"' -f4)" \
     -o /dev/null -w '%{http_code} %{redirect_url}\n'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   303 https://storefront-x7k2m.sevalla.app/user/1?check_logged_in=1
   ```

   The session cookie's name starts with `SSESS`, Drupal's prefix for cookies it sends only over HTTPS:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   grep -o 'SSESS' cookies.txt
   ```

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

## Keep uploaded files on a disk

An instance's own files last only until the next deployment or restart: each deployment starts new instances from the image. Drupal writes the files that editors upload, the image styles it generates from them, and its aggregated CSS and JavaScript to `web/sites/default/files`, the `public://` folder. Without a disk, uploads return `404` after the next deployment, even though their content still lists them.

The disk you created at `/app/web/sites/default/files` keeps the folder across deployments and restarts. Sevalla mounts it writable for user 1000, which Drupal runs as. A disk has limits, from [Persistent storage](https://docs.sevalla.com/applications/disks.md#how-disks-work):

* The web process runs 1 instance, and can't autoscale. Scale it up with a larger size instead.
* The site is unavailable for a few moments during each deployment, while Sevalla moves the disk to the new instance.
* Only the web process sees the disk: the `Drush` job and other processes don't. Drush commands that change files in `public://` belong in the web process, through the **Web terminal** tab.

Choose the disk for a site that runs on one instance, which is most Drupal sites. If the site needs more than one instance or no interruption during deployments, store the public files in Sevalla [object storage](https://docs.sevalla.com/object-storage/overview.md) instead, with a Drupal module that stores them in an S3-compatible bucket, and don't create the disk. Give the module the bucket's endpoint, name, access key and secret key from [Connect with the S3 API](https://docs.sevalla.com/object-storage/connect.md), and keep the keys in environment variables that `settings.php` reads.

![The Create disk sheet with the Process, Path and Size fields](https://assets.sevalla.com/screenshots/light/app-disks-create.png)

## Add modules and themes

Add a module or a theme from Drupal.org with Composer, as you added Drush, so every deployment has it. Composer puts modules in `web/modules/contrib` and themes in `web/themes/contrib`, which `.gitignore` keeps out of the repository: Ingot installs them from `composer.lock` during the build. Commit `composer.json` and `composer.lock` and push. When the deployment is **Deployed**, turn the module on, on the admin's **Extend** page: that changes the database, not the files, so it lasts. Update Drupal core and modules the same way, with `composer update`; the `Drush` job applies their database updates before the new version starts.

Drupal's scheduled tasks run with the Automated Cron module, which the standard profile turns on: Drupal runs them after a page request, at most every 3 hours.

## 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. It uses the same `Caddyfile` as Ingot. Add a `Dockerfile` 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 Drupal needs
FROM dunglas/frankenphp:1-php8.5-trixie AS php
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN install-php-extensions gd pdo_pgsql \
    && cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
    && printf '%s\n' 'expose_php = Off' '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

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 GODEBUG=cgocheck=0 PORT=8080 XDG_CONFIG_HOME=/tmp/caddy XDG_DATA_HOME=/tmp/caddy
WORKDIR /app
COPY --from=build /app /app
RUN install -d -o 1000 -g 1000 web/sites/default/files config/sync
USER 1000:1000
EXPOSE 8080
CMD ["frankenphp", "run", "--config", "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` for Drupal core and `pdo_pgsql` for PostgreSQL. |
| The `php.ini` settings | PHP's production `php.ini`, without the PHP version in response headers, uploads of up to 64 MB, and OPcache and realpath cache sizes for Drupal's thousands of files, as in Ingot's image. OPcache keeps checking files for changes, as in Ingot's image for a PHP application without a framework it knows. |
| `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` and `composer.lock` copied before the source | The first `composer install` depends only on them, so a change to your code reuses its cached layer. It installs Drupal core into `web/core`, and Drupal's scaffold plugin copies `index.php` and the other scaffold files into `web/`. The second writes the optimized autoloader. |
| Final stage `debian:trixie-slim` | Debian 13 with PHP, FrankenPHP, Drupal 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` | Drupal doesn't run as root. Its files, `settings.php` included, stay owned by root, so it can read its code but not change it. Only `web/sites/default/files`, where the disk is mounted, and `config/sync`, where Drupal writes a protective `.htaccess` file, belong 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. The `Drush` job runs `sh scripts/deploy.sh` from the same image. |
| `ENV` | 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`. |

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.env*
vendor
web/core
web/libraries
web/modules/contrib
web/profiles/contrib
web/themes/contrib
web/sites/*/files
web/sites/*/settings.local.php
Dockerfile
.dockerignore
```

The `.dockerignore` keeps what Composer installs, local uploads and local settings out of the build, so the image gets what `composer install` produces from `composer.lock`.

Commit and push both files. Ingot doesn't build from your `Dockerfile`, and it adds the patterns in `.dockerignore` to its own (the build plan shows **Ignored** `12 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 `Drush has been successful`, and its **Dockerfile** tab shows your file. When the deployment's status is **Deployed**, the site and the sign-in work as in [Check from a terminal](#check-from-a-terminal): the application's folder is `/app` in both images, so the disk at `/app/web/sites/default/files` keeps the files, and the `Drush` job, the environment variables and the database 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 `Drush` job, which applies database updates and clears the caches, then replaces the web process's instance. Content and the configuration you change in the admin live in the database, so a deployment keeps them. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

`settings.php` reads the variables it needs with `getenv()`. To add a setting, such as an API key for a module, add it on the application's **Environment variables** tab and read it in `settings.php`, for example `$settings['example_api_key'] = getenv('EXAMPLE_API_KEY');`. Changes apply at the next deployment. 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 `DRUSH_OPTIONS_URI` to it, for example `https://shop.example.com`, and deploy. `trusted_host_patterns` then allows only the new domain. 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 build fails with `No index.php found` | The `Procfile` is missing, so Ingot looks for `index.php` in `public/`, `webroot/` or the root, and Drupal's is in `web/`. | Add the `Procfile` from [Serve the web folder with FrankenPHP](#serve-the-web-folder-with-frankenphp), commit and push. |
| The deployment fails at `Drush has failed`, and the job's runtime logs show `Failed to connect to your database server.` | The `DB_*` variables are missing or wrong, or the database isn't **Ready**. | Check the internal connection on the **Networking** tab and the `DB_*` variables on the **Environment variables** tab, then deploy. |
| Every page redirects to `/core/install.php`, which returns `404` | `DRUPAL_HASH_SALT` is missing, so Drupal treats the site as not installed. | Add `DRUPAL_HASH_SALT`, then deploy. |
| Uploaded images return `404` after a deployment | The files were on the instance's own files, not on a disk. | Create the disk at `/app/web/sites/default/files`, deploy, and upload the files again. |

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`), [List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) and [List disk resource types](https://api-docs.sevalla.com/v3/resources/list-disk-resource-types) (`listDiskResourceTypesV3`) give the IDs of **DB1**, **H1** and the 10 GB disk:

```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')
DISK_ID=$(curl -s "$API/resources/disk-resource-types" -H "$H" | jq -r '.[] | select(.size_gib == 10) | .id')
```

CLI: `sevalla resources db-types`, `sevalla resources process-types` and `sevalla resources disk-types`. MCP: `get_platform_options` for the database and process sizes, and `sevalla_api_read` with `listDiskResourceTypesV3` for the disk.

**3. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) with PostgreSQL 18. 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: "drupal", db_user: "drupal", 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"
```

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

CLI: `sevalla databases create --name storefront-db --type postgresql --db-version 18 --cluster $CLUSTER_ID --resource-type $DB1_ID --db-name drupal --db-user drupal --db-password $DB_PASSWORD`. MCP: not available for this step. `create_database` generates a password that it never returns, and `settings.php` needs it in `DB_PASSWORD`: run this call with the API or the CLI.

**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 `DRUSH_OPTIONS_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 `DRUSH_OPTIONS_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), then a new hash salt, the admin password and `DRUSH_OPTIONS_URI`, all for runtime only. Keep `DRUPAL_ADMIN_PASSWORD` to sign in:

```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)
DRUPAL_ADMIN_PASSWORD=$(openssl rand -hex 16)
for pair in "DB_HOST=$DB_HOST" "DB_PORT=$DB_PORT" "DB_DATABASE=drupal" "DB_USERNAME=drupal" \
  "DB_PASSWORD=$DB_PASSWORD" "DB_URL=postgres://drupal:$DB_PASSWORD@$DB_HOST:$DB_PORT/drupal" \
  "DRUPAL_HASH_SALT=$(openssl rand -hex 32)" "DRUPAL_ADMIN_PASSWORD=$DRUPAL_ADMIN_PASSWORD" \
  "DRUSH_OPTIONS_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 of the 9 keys as it's added. The build reads none of them.

CLI: `sevalla apps env-vars create $APP_ID --key DRUSH_OPTIONS_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 Drush 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: "Drush", type: "job", entrypoint: "sh scripts/deploy.sh", resource_type_id: $size,
      job_start_policy: "beforeDeployment", scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
  | jq -c '{display_name, job_start_policy}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"display_name":"Drush","job_start_policy":"beforeDeployment"}
```

CLI: `sevalla apps processes create $APP_ID --name Drush --type job --resource-type $H1_ID --command "sh scripts/deploy.sh" --job-start-policy beforeDeployment`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**9. Create the disk**

[Attach or update process disk](https://api-docs.sevalla.com/v3/applications/attach-or-update-process-disk) (`updateProcessDiskV3`) attaches the disk to the web process, the API's equivalent of **Create disk** on the **Disks** tab:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PUT "$API/applications/$APP_ID/processes/$WEB_ID/disk" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg size "$DISK_ID" '{resource_type_id: $size, mount_path: "/app/web/sites/default/files"}')" \
  | jq -c '{size_gib, mount_path}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"size_gib":10,"mount_path":"/app/web/sites/default/files"}
```

CLI: `sevalla apps processes disk update $WEB_ID --app-id $APP_ID --resource-type $DISK_ID --mount-path /app/web/sites/default/files`. MCP: `sevalla_api_update` with `updateProcessDiskV3` and the same body.

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

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

**12. 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, the `Drush` job 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`.

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

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