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
gdandpdo_pgsqlextensions, 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: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:
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.phppins 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_pgsqlextension, which Drupal core doesn’t require in itscomposer.json. Requiring it in yours makes Ingot install it, next to thegdextension that Drupal core requires.
Read the settings from environment variables
Createweb/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:
DB_HOST,DB_PORT,DB_DATABASE,DB_USERNAMEandDB_PASSWORDare the variables Sevalla adds to the application when you connect the database.hash_saltis 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_URIis the site’s address. Drush reads it for the links it prints, andtrusted_host_patternsallows requests for its host name only, which protects the site from requests with a forgedHostheader.config_sync_directoryis where Drupal exports its configuration, inconfig/syncnext toweb/. Create the folder with an emptyconfig/sync/.gitkeepfile, so it’s in the repository.
Serve the web folder with FrankenPHP
Ingot servesindex.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:
- FrankenPHP listens on the port in
PORTand servesweb/. A request for a path without a file of its own, such as/node/1, goes toindex.php. index.phpis the only PHP file that runs. A PHP file anywhere else, such as one in the uploads folder,/core/install.phpor/update.php, gets404. 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,.twigand.modulefiles, get404. - Requests from Sevalla’s proxy that arrived over HTTPS reach Drupal with
HTTPSon, so Drupal buildshttps://links and marks its session cookie secure, andREMOTE_ADDRis the visitor’s address from theCF-Connecting-IPheader rather than the proxy’s. Drupal needs noreverse_proxysettings.
Procfile that tells Ingot to start FrankenPHP with it:
Install and update the site with Drush
Add the script that theDrush job runs before each deployment:
- On the first deployment, the database is empty, so
drush statusdoesn’t reportConnected, anddrush site:installinstalls Drupal with the standard profile and anadminaccount whose password isDRUPAL_ADMIN_PASSWORD. Drush doesn’t print the password when you set it. - On every deployment,
drush updatedbapplies the database updates of a new Drupal core or module release, anddrush cache:rebuildclears Drupal’s caches, which live in the database, for the new code. - The image has no PostgreSQL client, so
drush site:installcan’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 startsDrush was unable to drop all tablesand endsDrupal 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 intoweb/. It installs thegdandpdo_pgsqlextensions. - Starts the command from the
Procfile. The build plan’s Document root row shows/app, the root of Ingot’s own configuration, which theProcfilereplaces: yourCaddyfileserves/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
Drushjob does.
Deploy to Sevalla
These steps build with Ingot. To build from your own Dockerfile instead, see Deploy with a Dockerfile.Create the database
storefront-db as the Display name, choose a Data center and a Size, then click Create database. See Create a database for every field.Create the application
Connect the database
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.Add the environment variables
Create the Drush job
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.Create the disk
/app/web/sites/default/files as the Path, choose a Size, and click Create disk. See Keep uploaded files on a disk.Deploy
Turn on auto-deploy


Verify the deployment
-
On the Deployments tab, click the deployment. Its Build log shows Ingot’s build plan: PHP 8.5 from
composer.json, thepdo_pgsqlandgdextensions and where each comes from, and the start command from theProcfile.Later, during the rollout, the build log showsBuild logDrush 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. - Wait for the deployment’s status to change to Deployed. The application’s status next to its name changes to Running.
-
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/loginto the address, sign in asadminwith the password inDRUPAL_ADMIN_PASSWORD, and the administration toolbar appears.


Check from a terminal
These checks need no dashboard. The API calls useAPI, H, APP_ID, WEB_ID and DEPLOYMENT_ID as set in Deploy with the API, and DRUPAL_ADMIN_PASSWORD from the same sequence.
-
The deployment’s status, from Get deployment (
getDeploymentV3): -
The rollout’s results in the build log, from Get application deployment logs (
getApplicationDeploymentLogsV3). The messages carry terminal color codes, whichgsubremoves, andselectdrops the lines of the image build, which start with#: -
The installation, in the job’s output, from Get application runtime logs (
getApplicationRuntimeLogsV3), which searches the last hour by default: -
The disk on the web process, from Get process (
getProcessV3): -
Drupal’s status report, with Run a command in a process (
execProcessCommandV3) in the web process.--severity=2lists 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: -
The front page’s title and its RSS link, which Drupal builds with
https://because FrankenPHP passes HTTPS on: -
A sign-in as
admin. The first request gets the sign-in form and itsform_build_id, the second sends the form:The session cookie’s name starts withSSESS, 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 toweb/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
Drushjob and other processes don’t. Drush commands that change files inpublic://belong in the web process, through the Web terminal tab.
settings.php reads.


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 inweb/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 sameCaddyfile as Ingot. Add a Dockerfile and a .dockerignore to the root of the repository:
.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:
Choose the Dockerfile build strategy
Dockerfile and the Docker context ., then click Update build strategy.Deploy
📦 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 theDrush 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 setDRUSH_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
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
400and a message that starts withCannot access repositoryand says where to connect one. curl,jqandopenssl.
Choose the location
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:sevalla resources clusters. MCP: get_platform_options.Look up the sizes
listDatabaseResourceTypesV3), List process resource types (listProcessResourceTypesV3) and List disk resource types (listDiskResourceTypesV3) give the IDs of DB1, H1 and the 10 GB disk: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.Create the 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:creating. Poll Get database (getDatabaseV3) until it’s ready. On error, delete the database and create it again: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.Create the 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: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.Size the web process
listProcessesV3) finds it, and Update process (updateProcessV3) sets H1:sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID. MCP: scale_process with size_id and apply_now: false.Connect the database
createApplicationInternalConnectionV3):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.Add the environment variables
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: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.Create the Drush job
createProcessV3) needs the size, a scaling_strategy and, for a job, its job_start_policy: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.Create the disk
updateProcessDiskV3) attaches the disk to the web process, the API’s equivalent of Create disk on the Disks tab: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.Deploy
triggerDeploymentV3):sevalla apps deployments trigger $APP_ID --branch $BRANCH. MCP: trigger_deployment.Wait for the 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:sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID. MCP: get_deployment.Read the build log
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:sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID. MCP: get_deployment_logs.Turn on auto-deploy
updateApplicationV3):{"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.$APP_HOST as the domain.