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

> Deploy WordPress 7.1 to Sevalla with a MariaDB or MySQL database: settings in environment variables, uploads on a disk, a cron job for scheduled events, and Ingot or a Dockerfile.

WordPress runs on Sevalla as an application built from a Git repository that holds the WordPress files: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) recognizes WordPress, installs the PHP extensions it uses, and serves it with FrankenPHP in the web process on the port that Sevalla sets in the `PORT` environment variable. The content lives in a Sevalla MariaDB or MySQL database, uploads live on a disk, and a cron job runs WordPress's scheduled events.

## At a glance

| | |
| - | - |
| Source | A Git repository that holds the WordPress files |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (PHP 8.5 from `ingot.json`), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `frankenphp run --config /etc/frankenphp/Caddyfile --adapter caddyfile`, which Ingot sets, serving the repository root |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `WordPress cron` cron job (`php wp-cron.php`, schedule `*/5 * * * *`) |
| Services | MariaDB or MySQL over an internal connection |
| Disk | 10 GB on the web process at `/app/wp-content/uploads`, which limits the web process to 1 instance |
| Health check | None |
| Size | **H1** for the web process and the cron job; **DB1** for the database to start, **DB2** or larger in production, because MariaDB and MySQL can run out of memory on DB1 under load |

### 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: `3306` |
| `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: `mysql://<user>:<password>@<host>:<port>/<database>`. `wp-config.php` doesn't read it. |
| `AUTH_KEY`, `SECURE_AUTH_KEY`, `LOGGED_IN_KEY`, `NONCE_KEY`, `AUTH_SALT`, `SECURE_AUTH_SALT`, `LOGGED_IN_SALT`, `NONCE_SALT` | Yes | Runtime | Yes | Generate each with `openssl rand -hex 32` |
| `WP_HOME` | 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).
* `curl`, `tar`, `unzip` and `openssl` on your computer. macOS and most Linux distributions include them. You don't need PHP locally: Sevalla builds and runs WordPress.

## Create the project

Download WordPress into a folder named `storefront`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -L https://wordpress.org/latest.tar.gz | tar -xz
mv wordpress storefront
cd storefront
```

`latest.tar.gz` is always the newest WordPress release. This guide was tested with WordPress 7.1.

Create `wp-config.php` next to `wp-config-sample.php`. It reads the database connection, the keys and the site's address from environment variables, so no secret is in the repository:

```php wp-config.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<?php
// The database: the variables Sevalla adds when you connect the database
define( 'DB_NAME', getenv( 'DB_DATABASE' ) );
define( 'DB_USER', getenv( 'DB_USERNAME' ) );
define( 'DB_PASSWORD', getenv( 'DB_PASSWORD' ) );
define( 'DB_HOST', getenv( 'DB_HOST' ) . ':' . getenv( 'DB_PORT' ) );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );

// Authentication keys and salts
foreach ( array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' ) as $name ) {
	define( $name, getenv( $name ) );
}

$table_prefix = 'wp_';

// The site's address, such as https://shop.example.com
define( 'WP_HOME', getenv( 'WP_HOME' ) );
define( 'WP_SITEURL', getenv( 'WP_HOME' ) );

// A Sevalla cron job runs WordPress's scheduled events
define( 'DISABLE_WP_CRON', true );

// Plugins, themes and core updates come from the repository, not the admin
define( 'DISALLOW_FILE_MODS', true );

define( 'WP_DEBUG', false );

if ( ! defined( 'ABSPATH' ) ) {
	define( 'ABSPATH', __DIR__ . '/' );
}

require_once ABSPATH . 'wp-settings.php';
```

* `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`, `DB_HOST` and `DB_PORT` are the variables Sevalla adds to the application when you connect the database.
* The eight keys and salts sign the cookies that keep users signed in. Each one is an environment variable that you generate once.
* `WP_HOME` is the site's address. Setting `WP_HOME` and `WP_SITEURL` here keeps WordPress from reading them from the database, so moving the site to another domain is a change to one variable.
* `DISABLE_WP_CRON` and `DISALLOW_FILE_MODS` are explained in [Run scheduled events](#run-scheduled-events) and [Add plugins and themes](#add-plugins-and-themes).

Keep uploaded files out of the repository:

```text .gitignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
wp-content/uploads/
```

## Prepare the project for Sevalla

### Pin the PHP version

WordPress has no `composer.json`, so set the PHP version in `ingot.json`:

```json ingot.json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "versions": { "php": "8.5" }
}
```

Ingot builds on PHP 8.5 and follows its patch releases. WordPress 7.1 supports PHP 7.4 to 8.5, and Ingot 0.4.1 knows PHP 8.2 to 8.5. Without the file, Ingot uses its default line, which a new Ingot release can move. See [Pin the PHP version](https://docs.sevalla.com/guides/php/overview.md#pin-the-php-version).

### What Ingot reads

| File | Why Sevalla needs it |
| - | - |
| `index.php` | At the root of the repository, it tells Ingot that the application is PHP and that FrankenPHP serves the root. |
| `wp-settings.php` | Tells Ingot that the application is WordPress, so it installs the `exif`, `gd`, `intl`, `mysqli` and `zip` extensions. |
| `ingot.json` | Sets the PHP version. |
| `wp-config.php` | Reads the settings from environment variables. |

### How Ingot builds and starts WordPress

The project needs no build or start configuration:

* WordPress has no Composer packages and no build step, so Ingot copies the files into the image.
* FrankenPHP serves the repository root on `PORT` (`8080` by default). A request for a path without a file goes to `index.php`, so pretty permalinks such as `/hello-world/` work without an `.htaccess` file. FrankenPHP doesn't read `.htaccess`, so rules that plugins write there have no effect. Files that start with a dot are never served.
* FrankenPHP sets `HTTPS` for requests that reached Sevalla over HTTPS, and `REMOTE_ADDR` to the visitor's address. WordPress builds `https://` links, and the admin sign-in works without the `X-Forwarded-Proto` code that WordPress suggests for sites behind a proxy.
* PHP accepts uploads of up to 64 MB.

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 **MariaDB** or **MySQL** and choose the newest version. 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. Generate the keys**

On your computer, print eight random keys, one per line:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for name in AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY AUTH_SALT SECURE_AUTH_SALT LOGGED_IN_SALT NONCE_SALT; do
  echo "$name=$(openssl rand -hex 32)"
done
```

**5. Add the environment variables**

Click the **Environment variables** tab, then click **Add environment variables**. Paste the eight lines into the first **Key** field: the sheet adds one variable per line. Click **Add another**, enter `WP_HOME` as the **Key** and the application's URL as the **Value**, for example `https://storefront-x7k2m.sevalla.app` (the domain is under the application's name in the page header). Click **Add environment variables**.

**6. Create the cron job**

Click the **Processes** tab, then click **Create process**. Choose **Cron job**, enter `WordPress cron` as the **Name**, `php wp-cron.php` as the **Custom start command** and `*/5 * * * *` as the **Schedule** (every 5 minutes), then click **Create process**. See [Run scheduled events](#run-scheduled-events).

**7. Create the disk**

Click the **Disks** tab, then click **Create disk**. Choose the **Web** process, enter `/app/wp-content/uploads` as the **Path**, choose a **Size**, and click **Create disk**. See [Keep uploads between deployments](#keep-uploads-between-deployments).

**8. Deploy**

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

**9. 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 Add environment variables sheet with the Availability checkboxes and rows of keys and values](https://assets.sevalla.com/screenshots/light/app-environment-variables-add.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 `ingot.json`, the extensions WordPress uses, and the repository root as the document root.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ PHP 8.5 application ──────────────────────────────────────╮
   │ PHP            8.5                          ingot.json "8.5" │
   │ Extensions     exif, gd, intl, mysqli, zip  WordPress        │
   │ Document root  /app                         index.php        │
   ├─ 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 ─╯
   ```

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`. WordPress opens its installation page, **Welcome**. Enter the **Site Title**, a **Username**, a **Password** and **Your Email**, then click **Install WordPress**. On the **Success!** page, click **Log In** and sign in. The dashboard opens at `https://storefront-x7k2m.sevalla.app/wp-admin/`.

![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 page shows `Error establishing a database connection`, see [Troubleshoot the deployment](#troubleshoot-the-deployment).

### Check from a terminal

These checks need no dashboard. The API calls use `API`, `H`, `APP_ID` and `DEPLOYMENT_ID` as set in [Deploy with the API](#deploy-with-the-api). `WEB_ID` and `CRON_ID` are the web process and the cron job 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"}}
   🚀 Web has been deployed successfully.
   🚀 WordPress cron has been deployed successfully.
   ```

3. 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/wp-content/uploads"},"scaling_strategy":{"type":"manual","config":{"instanceCount":1}}}
   ```

4. Install WordPress without a browser: the same form as the **Welcome** page, sent to `install.php`. Run it right after the first deployment, because until WordPress is installed, anyone who opens the site can install it. Keep the password:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   WP_ADMIN_PASSWORD=$(openssl rand -hex 16)
   curl -s "https://storefront-x7k2m.sevalla.app/wp-admin/install.php?step=2" \
     --data-urlencode "weblog_title=Storefront" --data-urlencode "user_name=admin" \
     --data-urlencode "admin_password=$WP_ADMIN_PASSWORD" --data-urlencode "admin_password2=$WP_ADMIN_PASSWORD" \
     --data-urlencode "admin_email=owner@shop.example.com" --data-urlencode "blog_public=1" \
     | grep -o "<h1>[^<]*</h1>"
   ```

   ```html theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   <h1>Success!</h1>
   ```

   On a site that's installed already, the same request returns `<h1>Already Installed</h1>` and changes nothing. The home page then has the site's title:

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

   ```html theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   <title>Storefront</title>
   ```

5. A run of the cron job, with [Trigger cron job](https://api-docs.sevalla.com/v3/applications/trigger-cron-job) (`triggerProcessCronV3`), the API's **Run now**, then [List cron runs](https://api-docs.sevalla.com/v3/applications/list-cron-runs) (`listProcessCronRunsV3`) about a minute later:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -X POST "$API/applications/$APP_ID/processes/$CRON_ID/trigger" -H "$H" | jq -r .message
   sleep 60
   curl -s "$API/applications/$APP_ID/processes/$CRON_ID/runs" -H "$H" | jq -c '.data[0] | {trigger, status, exit_code}'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Cron job triggered
   {"trigger":"manual","status":"succeeded","exit_code":0}
   ```

## Keep uploads between deployments

An instance's own files last only until the next deployment or restart: each deployment starts new instances from the image. Anything WordPress writes into its folder while it runs is gone after the next deployment unless it's on a disk: images and files uploaded to the media library in `wp-content/uploads/`, plugins and themes installed from the admin, and updates installed from the admin. The media library still lists the uploads, but their files return `404`.

The disk you created at `/app/wp-content/uploads` keeps the media library's files across deployments and restarts. Sevalla mounts it writable for user 1000, which WordPress 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 cron job and other processes don't.

Choose the disk for a site that runs on one instance, which is most WordPress sites. If the site needs more than one instance or no interruption during deployments, store uploads in Sevalla [object storage](https://docs.sevalla.com/object-storage/overview.md) instead, with a WordPress plugin that offloads the media library to an S3-compatible bucket, and don't create the disk. Each plugin has its own settings: give it 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.

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

## Add plugins and themes

`DISALLOW_FILE_MODS` in `wp-config.php` removes the admin's buttons that install and update plugins, themes and WordPress itself, because the next deployment would undo what they change. Add plugins and themes to the repository instead, so every deployment has them. For example, to add the Classic Editor plugin from WordPress.org:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -LO https://downloads.wordpress.org/plugin/classic-editor.latest-stable.zip
unzip classic-editor.latest-stable.zip -d wp-content/plugins
rm classic-editor.latest-stable.zip
git add wp-content/plugins/classic-editor
git commit -m "Add Classic Editor"
git push
```

When the deployment is **Deployed**, activate the plugin on the admin's **Plugins** page: activating and configuring plugins changes the database, not the files, so it works with `DISALLOW_FILE_MODS`. To update a plugin, theme or WordPress itself, replace its files in the repository with the new release and push.

## Run scheduled events

WordPress normally starts its scheduled events, such as publishing scheduled posts and checking for updates, when someone views a page, so on a quiet site they run late. `DISABLE_WP_CRON` turns that off, and the `WordPress cron` cron job runs `php wp-cron.php` every 5 minutes instead: it runs the events that are due, then exits.

To check it, on the **Processes** tab, click **⋯** on the cron job, click **Run now**, then click **Run now** in the dialog. When the run ends, the cron job's status changes to **Succeeded**. See [Cron jobs](https://docs.sevalla.com/applications/processes.md#cron-jobs).

The cron job runs in its own instance from the same image, with the same environment variables, so it reaches the database. It doesn't see the disk: events that write files to `wp-content/uploads/` belong in the web process.

## 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. This one builds on FrankenPHP's image rather than the official `wordpress` image. The `wordpress` image runs Apache as root on port 80 and copies its own WordPress files into place when the container starts; using it with a repository that already holds WordPress means reconfiguring Apache for `PORT`, dropping root and bypassing its entrypoint. FrankenPHP's image is the one Ingot uses, so WordPress behaves the same with both build strategies.

Add a `Dockerfile`, a `Caddyfile` and a `.dockerignore` to the root of the repository:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# PHP 8.5, the line ingot.json sets, with FrankenPHP and the extensions WordPress uses
FROM dunglas/frankenphp:1-php8.5-trixie AS php
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN install-php-extensions exif gd intl mysqli zip \
    && cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
    && printf '%s\n' 'expose_php = Off' 'opcache.validate_timestamps = 0' \
        '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 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
COPY Caddyfile /etc/frankenphp/Caddyfile
WORKDIR /app
COPY . .
RUN install -d -o 1000 -g 1000 wp-content/uploads
USER 1000:1000
EXPOSE 8080
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]
```

| Line or choice | Why |
| - | - |
| `dunglas/frankenphp:1-php8.5-trixie` | FrankenPHP's official image with PHP 8.5, the line `ingot.json` 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 the extensions WordPress uses. |
| The `php.ini` settings | PHP's production `php.ini`, without the PHP version in response headers, and uploads of up to 64 MB. The code in the image never changes, so OPcache doesn't check the files on every request. |
| `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. |
| Final stage `debian:trixie-slim` | Debian 13 with PHP, FrankenPHP and WordPress, without 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 line before `apt-get` tells hadolint to skip its version-pinning check. |
| `USER 1000:1000` | WordPress doesn't run as root. Its files stay owned by root, so it can read its code but not change it; only `wp-content/uploads/`, where the media library writes, belongs to user 1000. |
| `CMD ["frankenphp", "run", ...]` | FrankenPHP is the container's main process, so it gets Sevalla's `SIGTERM` itself and finishes the requests in progress before it stops. |
| `ENV` | FrankenPHP keeps its own files in `/tmp`, which user 1000 can write to, and `GODEBUG=cgocheck=0` is set as FrankenPHP's own image sets it. `EXPOSE 8080` documents Sevalla's default `PORT`. |

The `Caddyfile` configures FrankenPHP the way Ingot does, plus one rule for the uploads folder:

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

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

	@hidden path */.* /Caddyfile
	respond @hidden 404

	@uploaded_scripts path_regexp (?i)^/wp-content/uploads/.*\.php
	respond @uploaded_scripts 404

	@https {
		remote_ip private_ranges
		header X-Forwarded-Proto https
	}
	vars @https https on

	php_server {
		env HTTPS {vars.https}
		env REMOTE_ADDR {client_ip}
	}
}
```

* FrankenPHP listens on the port in `PORT` and serves the WordPress files. A path without a file goes to `index.php`, for pretty permalinks. Files that start with a dot, and the `Caddyfile` itself, get `404`.
* A `.php` file in `wp-content/uploads/` gets `404` instead of running, so a script that ends up in the uploads folder can't run. Ingot's configuration doesn't have this rule.
* Requests from Sevalla's proxy that arrived over HTTPS reach WordPress with `HTTPS` on, and `REMOTE_ADDR` is the visitor's address from the `CF-Connecting-IP` header rather than the proxy's.

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.env*
wp-content/uploads
ingot.json
Dockerfile
.dockerignore
```

The `.dockerignore` keeps local uploads and files that only Ingot or Docker read out of the image.

Commit and push the three files. Ingot doesn't build from your `Dockerfile` or use your `Caddyfile`, and it adds the patterns in `.dockerignore` to its own (the build plan shows **Ignored** `6 patterns from .dockerignore`), so you can keep the files in a repository that builds with Ingot. Ingot reads `ingot.json` before it applies the patterns, so the PHP version still comes from it. 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, and its **Dockerfile** tab shows your file. When the deployment's status is **Deployed**, the site and the admin work as before: the application's folder is `/app` in both images, so the disk at `/app/wp-content/uploads` keeps the uploads, and the cron job runs `php wp-cron.php` 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, then replaces the web process's instance. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

`wp-config.php` reads the variables it needs with `getenv()`. To add a setting, such as an API key for a plugin, add it on the application's **Environment variables** tab and read it in `wp-config.php` with `define( 'NAME', getenv( 'NAME' ) );`. 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 `WP_HOME` to it, for example `https://shop.example.com`, and deploy. Custom domains need a web process larger than **H1**, so [scale](https://docs.sevalla.com/applications/scaling.md) the web process first. WordPress stores full URLs in posts, so links to media uploaded before the change still use the old domain.

## Troubleshoot the deployment

| Symptom | Cause | Fix |
| - | - | - |
| The site shows `Error establishing a database connection` | 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. |
| Images in the media library return `404` after a deployment | The uploads were on the instance's own files, not on a disk. | Create the disk at `/app/wp-content/uploads`, deploy, and upload the files again. |
| The admin has no button to add or update plugins | `DISALLOW_FILE_MODS` is on. | Add or update plugins in the repository. See [Add plugins and themes](#add-plugins-and-themes). |

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 and the MariaDB version**

[List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`), [List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`), [List disk resource types](https://api-docs.sevalla.com/v3/resources/list-disk-resource-types) (`listDiskResourceTypesV3`) and [List database versions](https://api-docs.sevalla.com/v3/resources/list-database-versions) (`listDatabaseVersionsV3`) give the IDs of **DB1**, **H1** and the 10 GB disk, and the newest MariaDB version in the location. For production, pick `db2` instead of `db1`:

```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')
MARIADB_VERSION=$(curl -s "$API/resources/database-versions?cluster_id=$CLUSTER_ID" -H "$H" \
  | jq -r '.[] | select(.type == "mariadb") | .versions[-1]')
```

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

**3. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`). You choose the password, and the API never returns it, so keep it in a variable for the environment variables:

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

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

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

CLI: `sevalla databases create --name storefront-db --type mariadb --db-version $MARIADB_VERSION --cluster $CLUSTER_ID --resource-type $DB1_ID --db-name wordpress --db-user wordpress --db-password $DB_PASSWORD`. MCP: not available for this step. `create_database` generates a password that it never returns, and `wp-config.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 `WP_HOME`:

```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 `WP_HOME` 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 eight new keys and salts and `WP_HOME`, all for runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_HOST=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r .internal_hostname)
DB_PORT=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r .internal_port)
pairs=("DB_HOST=$DB_HOST" "DB_PORT=$DB_PORT" "DB_DATABASE=wordpress" "DB_USERNAME=wordpress"
  "DB_PASSWORD=$DB_PASSWORD" "DB_URL=mysql://wordpress:$DB_PASSWORD@$DB_HOST:$DB_PORT/wordpress" "WP_HOME=https://$APP_HOST")
for name in AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY AUTH_SALT SECURE_AUTH_SALT LOGGED_IN_SALT NONCE_SALT; do
  pairs+=("$name=$(openssl rand -hex 32)")
done
for pair in "${pairs[@]}"; 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 15 keys as it's added.

CLI: `sevalla apps env-vars create $APP_ID --key WP_HOME --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 cron job**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) with `type` `cron` needs the size, a `scaling_strategy` and the `schedule`, in UTC unless you add a `time_zone`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CRON_ID=$(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: "WordPress cron", type: "cron", entrypoint: "php wp-cron.php", schedule: "*/5 * * * *",
      resource_type_id: $size, scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
  | jq -r .id)
```

CLI: `sevalla apps processes create $APP_ID --name "WordPress cron" --type cron --resource-type $H1_ID --command "php wp-cron.php" --schedule "*/5 * * * *"`. 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/wp-content/uploads"}')" \
  | jq -c '{size_gib, mount_path}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"size_gib":10,"mount_path":"/app/wp-content/uploads"}
```

CLI: `sevalla apps processes disk update $WEB_ID --app-id $APP_ID --resource-type $DISK_ID --mount-path /app/wp-content/uploads`. 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"
```

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

**12. Turn on auto-deploy**

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

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

To build from the [Dockerfile](#deploy-with-a-dockerfile) instead of Ingot, send `{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}` with the same call before you deploy. CLI: `sevalla apps update $APP_ID --data '{"auto_deploy": true}'`. MCP: `sevalla_api_update` with `updateApplicationV3` and the same body.

Then install WordPress and run the checks in [Check from a terminal](#check-from-a-terminal), with `https://$APP_HOST` as the domain.


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