Skip to main content
Drupal runs on Sevalla as an application built from a Composer project in your Git repository: Ingot 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

Environment variables

Before you begin

  • A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See Git providers.
  • 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:
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:
.gitignore
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

  • require.php pins the PHP line: Ingot builds on PHP 8.5 and follows its patch releases. See 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:
web/sites/default/settings.php
  • 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:
Caddyfile
  • 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:
Procfile

Install and update the site with Drush

Add the script that the Drush job runs before each deployment:
scripts/deploy.sh
  • 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

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:

Deploy to Sevalla

These steps build with Ingot. To build from your own Dockerfile instead, see 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 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.
4

Add the environment variables

Click the Environment variables tab, then click Add environment variables. Add these variables and click Add environment variables:
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.
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.
The Create process sheet with Job selected, the Name and Custom start command fields, the Start policy set to Before deployment, and the H1 resourceThe Create process sheet with Job selected, the Name and Custom start command fields, the Start policy set to Before deployment, and the H1 resource

Verify the deployment

  1. On the Deployments tab, click the deployment. Its Build log shows Ingot’s 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.
    Build log
    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 applicationThe application header with its name, Running status, repository, branch and the domain that opens the application
If the deployment fails, read its build log, then see 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, and DRUPAL_ADMIN_PASSWORD from the same sequence.
  1. The deployment’s status, from Get deployment (getDeploymentV3):
  2. The rollout’s results in the build log, from 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 #:
  3. The installation, in the job’s output, from Get application runtime logs (getApplicationRuntimeLogsV3), which searches the last hour by default:
  4. The disk on the web process, from Get process (getProcessV3):
  5. Drupal’s status report, with 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:
  6. The front page’s title and its RSS link, which Drupal builds with https:// because FrankenPHP passes HTTPS on:
  7. A sign-in as admin. The first request gets the sign-in form and its form_build_id, the second sends the form:
    The session cookie’s name starts with SSESS, Drupal’s prefix for cookies it sends only over HTTPS:

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:
  • 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 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, and keep the keys in environment variables that settings.php reads.
The Create disk sheet with the Process, Path and Size fieldsThe Create disk sheet with the Process, Path and Size fields

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

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.

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.

Add a custom domain

Add the domain 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 the web process first.

Troubleshoot the deployment

For more causes, see Troubleshoot Ingot builds and Troubleshoot builds and deployments.

Deploy with the API

The same deployment as Deploy to Sevalla, as calls to the Sevalla API that a script or an AI agent runs in order. Each step names the matching CLI command and Sevalla MCP server tool. The sequence needs:
  • An API key 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:
1

Choose the location

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 lists the others. Display names differ between environments, so don’t select by them:
CLI: sevalla resources clusters. MCP: get_platform_options.
2

Look up the sizes

List database resource types (listDatabaseResourceTypesV3), List process resource types (listProcessResourceTypesV3) and List disk resource types (listDiskResourceTypesV3) give the IDs of DB1, H1 and the 10 GB disk:
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 (createDatabaseV3) with PostgreSQL 18. You choose the password, and the API never returns it, so keep it in a variable for the environment variables:
The database starts as creating. Poll Get database (getDatabaseV3) until it’s ready. On error, delete the database and create it again:
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 (createApplicationV3) creates it with a web process and doesn’t deploy it. List application domains (listApplicationDomainsV3) returns its sevalla.app domain as the primary one, for DRUSH_OPTIONS_URI:
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 (listProcessesV3) finds it, and Update process (updateProcessV3) sets H1:
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 (createApplicationInternalConnectionV3):
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 (createEnvironmentVariableV3) adds one variable per call. The loop adds the connection’s variables in the dashboard’s names and format, from 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:
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 (createProcessV3) needs the size, a scaling_strategy and, for a job, its job_start_policy:
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 (updateProcessDiskV3) attaches the disk to the web process, the API’s equivalent of Create disk on the Disks tab:
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 (triggerDeploymentV3):
CLI: sevalla apps deployments trigger $APP_ID --branch $BRANCH. MCP: trigger_deployment.
11

Wait for the deployment

Poll 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:
CLI: sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID. MCP: get_deployment.
12

Read the build log

Get application deployment logs (getApplicationDeploymentLogsV3) returns the build log, oldest line first: the build plan from 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:
CLI: sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID. MCP: get_deployment_logs.
13

Turn on auto-deploy

Update application (updateApplicationV3):
To build from the 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, with $APP_HOST as the domain.