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

> Deploy a CodeIgniter 4.7 application to Sevalla: FrankenPHP serves it, a job runs its migrations on PostgreSQL, and its logs go to the runtime logs.

A CodeIgniter 4 application runs on Sevalla as an application built from your Git repository: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) builds it as a PHP application, installs the Composer packages and the PHP extensions the project requires, and starts FrankenPHP in the web process, which serves `public/` on the port that Sevalla sets in the `PORT` environment variable. A job runs CodeIgniter's migrations before each deployment, and the data lives in a Sevalla PostgreSQL database.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (PHP 8.5, Composer), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `frankenphp run --config /etc/frankenphp/Caddyfile --adapter caddyfile`, which Ingot sets, serving `public/` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`php spark migrate --all`, **Before deployment**) |
| Services | PostgreSQL 18 over an internal connection |
| Disk | None |
| 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>`. The application doesn't read it. |
| `app_baseURL` | Yes | Runtime | No | `https://`, the application's domain and `/`, for example `https://storefront-x7k2m.sevalla.app/` |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

`CI_ENVIRONMENT` isn't set, so CodeIgniter runs in its `production` environment: it hides error details from visitors and logs only errors.

## 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 `intl` and `pgsql` extensions, and Composer 2, on your computer. CodeIgniter 4.7 needs PHP 8.2 or later.

## Create the project

Create a CodeIgniter project from the app starter:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
composer create-project codeigniter4/appstarter storefront
cd storefront
```

The app starter installs CodeIgniter 4.7 and its `.gitignore`, which keeps `vendor/`, the local `.env` file and the files in `writable/` out of the repository.

Add a `products` table. Create a migration:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
php spark make:migration CreateProducts
```

Spark writes a file in `app/Database/Migrations/` whose name starts with the current date and time. Replace its contents with a migration that creates the table and adds one product:

```php app/Database/Migrations/2026-10-30-075934_CreateProducts.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class CreateProducts extends Migration
{
    public function up()
    {
        $this->forge->addField([
            'id'    => ['type' => 'INT', 'auto_increment' => true],
            'name'  => ['type' => 'VARCHAR', 'constraint' => 100],
            'price' => ['type' => 'INT'],
        ]);
        $this->forge->addPrimaryKey('id');
        $this->forge->createTable('products');

        $this->db->table('products')->insert(['name' => 'Coffee mug', 'price' => 12]);
    }

    public function down()
    {
        $this->forge->dropTable('products');
    }
}
```

Add a model for the table:

```php app/Models/ProductModel.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<?php

namespace App\Models;

use CodeIgniter\Model;

class ProductModel extends Model
{
    protected $table         = 'products';
    protected $allowedFields = ['name', 'price'];
    protected array $casts   = ['id' => 'int', 'price' => 'int'];
}
```

Add a controller that returns the products as JSON:

```php app/Controllers/Products.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<?php

namespace App\Controllers;

use App\Models\ProductModel;

class Products extends BaseController
{
    public function index()
    {
        return $this->response->setJSON(model(ProductModel::class)->findAll());
    }
}
```

Then add two routes at the end of `app/Config/Routes.php`: `/products`, and `/catalog`, which redirects to it:

```php app/Config/Routes.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
$routes->get('products', 'Products::index');
$routes->addRedirect('catalog', 'products');
```

CodeIgniter builds the redirect's address from its base URL, so `/catalog` shows whether the application knows its own domain.

## Prepare the project for Sevalla

### Pin the PHP version and the PostgreSQL extension

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

* `require.php` pins the PHP line: Ingot builds on PHP 8.5 and follows its patch releases. The app starter's `^8.2` would build on Ingot's default line, which a new Ingot release can move. See [Pin the PHP version](https://docs.sevalla.com/guides/php/overview.md#pin-the-php-version).
* CodeIgniter's PostgreSQL driver, `Postgre`, uses PHP's `pgsql` extension. CodeIgniter doesn't require it in its own `composer.json`, so Ingot installs it only when your `composer.json` does.

### Connect to the database from environment variables

CodeIgniter reads its database settings from `app/Config/Database.php`. In the class's constructor, after `parent::__construct();`, point the `default` connection group at PostgreSQL with the variables that Sevalla adds when you connect the database:

```php app/Config/Database.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
    public function __construct()
    {
        parent::__construct();

        // PostgreSQL, with the variables Sevalla adds when you connect the database
        $this->default = [
            ...$this->default,
            'DBDriver' => 'Postgre',
            'hostname' => getenv('DB_HOST'),
            'port'     => getenv('DB_PORT'),
            'database' => getenv('DB_DATABASE'),
            'username' => getenv('DB_USERNAME'),
            'password' => getenv('DB_PASSWORD'),
            'charset'  => 'utf8',
        ];
```

Keep the rest of the constructor as it is. On your computer, put the same variables in a `.env` file, which CodeIgniter loads and Git ignores.

### Set the base URL

CodeIgniter builds the URLs of `site_url()`, `base_url()` and redirects from `app.baseURL`, which is `http://localhost:8080/` in `app/Config/App.php`. CodeIgniter reads an environment variable for any setting in a configuration class, with the dot written as an underscore, so you set `app_baseURL` on Sevalla to the application's address and leave the file as it is.

### Send the logs to the runtime logs

CodeIgniter writes its log to files in `writable/logs/`, which Sevalla resets on every deployment. Write it to PHP's error log instead, which FrankenPHP prints to the [runtime logs](https://docs.sevalla.com/applications/logs.md). In `app/Config/Logger.php`, import `ErrorlogHandler` in place of `FileHandler`, and replace the `$handlers` array:

```php app/Config/Logger.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
use CodeIgniter\Log\Handlers\ErrorlogHandler;

// ...

    public array $handlers = [
        // PHP's error log, which FrankenPHP writes to standard error: Sevalla's runtime logs
        ErrorlogHandler::class => [
            'handles'     => ['critical', 'alert', 'emergency', 'debug', 'error', 'info', 'notice', 'warning'],
            'messageType' => 0,
        ],
    ];
```

In `production`, the `$threshold` in the same file logs errors and the levels above them.

### 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-pgsql` adds the `pgsql` extension. |
| `composer.lock` | Ingot installs exactly these versions. The locked `codeigniter4/framework` requires `ext-intl`, so Ingot installs `intl` too. |
| `public/index.php` | The front controller. FrankenPHP serves `public/`, so `app/`, `writable/` and `vendor/` next to it are never served. |
| `app/Database/Migrations/` | The migrations that the migration job runs. |

### How Ingot builds and starts the project

The project needs no build or start configuration. Ingot doesn't recognize CodeIgniter by name, and doesn't need to:

* Runs `composer install --no-dev --optimize-autoloader` and installs the `intl` and `pgsql` extensions.
* Starts FrankenPHP, which serves `public/` on `PORT` (`8080` by default). A request for a path without a file of its own goes to `index.php`, which runs CodeIgniter's router. Files that start with a dot, such as `.htaccess`, are never served.
* Passes HTTPS and the visitor's IP address from Sevalla's proxy to PHP: FrankenPHP sets `HTTPS` for requests that reached Sevalla over HTTPS, and `REMOTE_ADDR` to the visitor's address.
* Lets the application write to its own folder, so CodeIgniter's cache, sessions and debug files work in `writable/`. What it writes there lasts until the next deployment and stays on the instance that wrote it.
* Doesn't run migrations: you create a job for them.

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`, the first five of which `app/Config/Database.php` reads. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

**4. Add the base URL**

Click the **Environment variables** tab, then click **Add environment variables**. Enter `app_baseURL` as the **Key** and the application's URL with a trailing slash 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**.

**5. Create the migration job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Migrate` as the **Name** and `php spark migrate --all` as the **Custom start command**. Keep the **Start policy** at **Before deployment** and the **Resource** at **H1**, then click **Create process**.

**6. Deploy**

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

**7. 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 `pgsql` and `intl` extensions and where each comes from, and `public/` as the document root.

   ```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       pgsql        composer.json                      │
   │                  intl         composer.lock                      │
   │ Package manager  Composer     composer.lock                      │
   │ Document root    /app/public  public/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.5.0 ─╯
   ```

   Later, during the rollout, the build log shows `Migrate has been successful`. If the job fails, the deployment fails and nothing changes for visitors.
2. Wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**.
3. Click the domain under the application's name, for example `storefront-x7k2m.sevalla.app`. CodeIgniter's welcome page opens, and its footer shows `Environment: production`. Add `/products` to the address: the page shows the product that the migration added.

![The application header with its name, Running status, repository, branch and the domain that opens the application](https://assets.sevalla.com/screenshots/light/app-overview-address.png)

If the deployment fails, read its build log, then see [Troubleshoot the deployment](#troubleshoot-the-deployment).

### Check from a terminal

These checks need no dashboard. The API calls use `API`, `H`, `APP_ID` and `DEPLOYMENT_ID` as set in [Deploy with the API](#deploy-with-the-api).

1. The deployment's status, from [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`):

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "$H" | jq -r .status
   ```

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

2. The rollout's results in the build log, from [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`). The messages carry terminal color codes, which `gsub` removes, and `select` drops the lines of the image build, which start with `#`:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?search=has%20been" -H "$H" \
     | jq -r '.[].message | gsub("\u001b\\[[0-9;]*m"; "") | select(startswith("#") | not)'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ✅ Migrate has been successful
   🚀 Web has been deployed successfully.
   ```

3. The migration 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=Migrations%20complete" -H "$H" | jq -r '.[].message'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Migrations complete.
   ```

4. The products, read from PostgreSQL, with your domain:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -w '\n' https://storefront-x7k2m.sevalla.app/products
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   [{"id":1,"name":"Coffee mug","price":12}]
   ```

5. The redirect from `/catalog`, whose address CodeIgniter builds from `app_baseURL`:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -sI https://storefront-x7k2m.sevalla.app/catalog | grep -i '^location'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   location: https://storefront-x7k2m.sevalla.app/products
   ```

6. The environment, from the welcome page:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://storefront-x7k2m.sevalla.app/ | grep -o 'Environment: [a-z]*'
   ```

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

## Read the logs

With the `ErrorlogHandler` from [Send the logs to the runtime logs](#send-the-logs-to-the-runtime-logs), every message CodeIgniter logs appears on the application's **Logs** tab. For example, when the application can't reach the database, `/products` returns `500` and the runtime logs show:

```text Runtime logs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CRITICAL --> CodeIgniter\Database\Exceptions\DatabaseException: Unable to connect to the database.
```

In `production`, CodeIgniter logs `error` and the levels above it, `critical`, `alert` and `emergency`. To log `warning`, `notice`, `info` or `debug` messages from `log_message()` too, raise `$threshold` in `app/Config/Logger.php`. See [Logs](https://docs.sevalla.com/applications/logs.md).

## Deploy with a Dockerfile

Ingot needs no Dockerfile. Write your own when you want full control of the image: the base image, the PHP extensions, the system packages and every layer. Add a `Dockerfile`, a `Caddyfile` and a `.dockerignore` to the root of the repository:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# PHP 8.5, the line composer.json requires, with FrankenPHP and the extensions CodeIgniter needs
FROM dunglas/frankenphp:1-php8.5-trixie AS php
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN install-php-extensions intl pgsql \
    && cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
    && printf '%s\n' 'expose_php = Off' 'opcache.validate_timestamps = 0' 'opcache.memory_consumption = 256' \
        'opcache.max_accelerated_files = 20000' 'realpath_cache_size = 4096K' 'realpath_cache_ttl = 600' \
        > "$PHP_INI_DIR/conf.d/production.ini" \
    && setcap -r /usr/local/bin/frankenphp \
    && rm -rf /usr/local/bin/php-cgi /usr/local/bin/phpdbg /usr/local/include
# List the Debian packages whose libraries PHP, FrankenPHP and the extensions load, for the final stage
RUN ldd /usr/local/bin/frankenphp /usr/local/bin/php /usr/local/lib/php/extensions/*/*.so \
    | awk '$3 ~ /^\/(usr\/)?lib\// { print $3 }' | sort -u | xargs realpath | xargs dpkg -S \
    | cut -d: -f1 | sort -u > /usr/local/share/php-runtime-packages

FROM php AS build
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
# hadolint ignore=DL3008
RUN apt-get update \
    && apt-get install -y --no-install-recommends unzip \
    && rm -rf /var/lib/apt/lists/*
ENV 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
COPY Caddyfile /etc/frankenphp/Caddyfile
WORKDIR /app
COPY --from=build /app /app
RUN chown -R 1000:1000 writable
USER 1000:1000
EXPOSE 8080
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]
```

| Line or choice | Why |
| - | - |
| `dunglas/frankenphp:1-php8.5-trixie` | FrankenPHP's official image with PHP 8.5, the line `require.php` pins, on Debian 13: the image Ingot builds on. A line tag, not a digest, so each build picks up PHP and FrankenPHP security fixes. Its `install-php-extensions` adds `intl`, which CodeIgniter requires, and `pgsql` for its PostgreSQL driver. |
| The `php.ini` settings | PHP's production `php.ini`, without the PHP version in response headers. The code in the image never changes, so OPcache doesn't check the files on every request. |
| `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. The second writes the optimized autoloader. |
| Final stage `debian:trixie-slim` | Debian 13 with PHP, FrankenPHP, the application and only the libraries they load, without Composer or the compilers that the PHP image keeps for building extensions. The packages aren't pinned to a version, so each build gets Debian's security updates, which is why the lines before the `apt-get` commands tell hadolint to skip its version-pinning check. |
| `USER 1000:1000` | The application doesn't run as root. Its files stay owned by root, so it can read its code but not change it; only `writable/`, where CodeIgniter keeps its cache and sessions, 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, and runs no PHP file but `index.php`:

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

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

	@hidden path */.*
	respond @hidden 404

	@scripts {
		path *.php
		not path /index.php
	}
	respond @scripts 404

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

	php_server {
		try_files {path} index.php
		env HTTPS {vars.https}
		env REMOTE_ADDR {client_ip}
	}
}
```

* FrankenPHP listens on the port in `PORT` and serves `public/`. Files that start with a dot get `404`, and so does every PHP file except `index.php`.
* Requests from Sevalla's proxy that arrived over HTTPS reach CodeIgniter 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*
vendor
writable/cache/*
writable/debugbar/*
writable/logs/*
writable/session/*
writable/uploads/*
tests
Dockerfile
.dockerignore
```

The `.dockerignore` keeps installed packages, your local `.env`, the files CodeIgniter wrote to `writable/` on your computer and the tests out of the build. The `index.html` files in `writable/` stay, so its folders exist in 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** `11 patterns from .dockerignore`), so you can keep the files in a repository that builds with Ingot. Then switch the application to the Dockerfile build strategy:

**1. Choose the Dockerfile build strategy**

On the application's **Settings** tab, in the **Build strategy** section, click **Update build strategy**. Click **Dockerfile**, keep the **Dockerfile path** `Dockerfile` and the **Docker context** `.`, then click **Update build strategy**.

**2. Deploy**

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

The deployment's build log shows `📦 Building and pushing docker image using Dockerfile...` instead of Ingot's build plan, then `Migrate has been successful`, and its **Dockerfile** tab shows your file. When the deployment's status is **Deployed**, `/products` and `/catalog` answer as in [Check from a terminal](#check-from-a-terminal). The `Migrate` job and the environment variables work unchanged. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md).

## Deploy changes

With **Auto-deploy** on, every push to the branch starts a deployment; otherwise, click **Deploy now** for each change. Each deployment builds the image, runs the `Migrate` job, then replaces the web process's instances. For a schema change, create a migration with `php spark make:migration` and commit it with the code. When you add a package, commit `composer.lock` with it. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

CodeIgniter reads an environment variable for any property of a configuration class in `app/Config/`: the class name in lower case, an underscore, then the property, as in `app_baseURL` for `$baseURL` in `App.php` or `email_SMTPHost` for `$SMTPHost` in `Email.php`. Add them on the application's **Environment variables** tab instead of committing a `.env` file. Changes apply at the next deployment. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

## Add a custom domain

To serve the application from your own domain, such as `shop.example.com`, add the domain on the application's **Domains** tab and create the DNS records that Sevalla shows, then set `app_baseURL` to `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. See [Custom domains](https://docs.sevalla.com/domains/overview.md).

## Troubleshoot the deployment

| Symptom | Cause | Fix |
| - | - | - |
| Links and redirects point at `http://localhost:8080/` | `app_baseURL` isn't set, so CodeIgniter uses the `$baseURL` in `app/Config/App.php`. | Add `app_baseURL` with the application's URL and a trailing slash, then deploy. |
| `/products` returns `500`, and the runtime logs show `CRITICAL --> CodeIgniter\Database\Exceptions\DatabaseException: Unable to connect to the database.` | 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. |
| The `Migrate` job fails, and its runtime logs show `The required PHP extension "pgsql" is not loaded. Install and enable it to use "Postgre" driver.` | `composer.json` doesn't require `ext-pgsql`, so the image has no `pgsql` extension. | Run `composer require "ext-pgsql:*"`, commit `composer.json` and `composer.lock`, then deploy. |

For more causes, see [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds) and [Troubleshoot builds and deployments](https://docs.sevalla.com/applications/troubleshooting/builds-and-deployments.md).

## Deploy with the API

The same deployment as [Deploy to Sevalla](#deploy-to-sevalla), as calls to the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) that a script or an AI agent runs in order. Each step names the matching [CLI](https://docs.sevalla.com/developer-tools/cli.md) command and [Sevalla MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool. The sequence needs:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, or the capabilities to create, read and update applications and databases, in `SEVALLA_API_KEY`.
* The repository pushed to GitHub, and a GitHub account with access to it connected to Sevalla. Connecting one is a dashboard step, and the API has no call that reports it: if no connected account can read the repository, the step that creates the application fails with `400` and a message that starts with `Cannot access repository` and says where to connect one.
* `curl`, `jq` and `openssl`.

Set the base URL, the authorization header and the repository:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
API=https://api.sevalla.com/v3
H="Authorization: Bearer $SEVALLA_API_KEY"
REPO_URL=https://github.com/acme/storefront
BRANCH=main
```

**1. Choose the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns every location your company can use, with its region ID in `location`. Pick one by region ID, here Frankfurt, Germany (`eu-frankfurt-1`); [Locations](https://docs.sevalla.com/platform/locations.md) lists the others. Display names differ between environments, so don't select by them:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CLUSTER_ID=$(curl -s "$API/resources/clusters" -H "$H" \
  | jq -r '.[] | select(.location == "eu-frankfurt-1") | .id')
```

CLI: `sevalla resources clusters`. MCP: `get_platform_options`.

**2. Look up the sizes**

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB1_ID=$(curl -s "$API/resources/database-resource-types" -H "$H" | jq -r '.[] | select(.name == "db1") | .id')
H1_ID=$(curl -s "$API/resources/process-resource-types" -H "$H" | jq -r '.[] | select(.name == "h1") | .id')
```

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

**3. Create the database**

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

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

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

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

```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 storefront --db-user storefront --db-password $DB_PASSWORD`. MCP: not available for this step. `create_database` generates a password that it never returns, and `app/Config/Database.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 `app_baseURL`:

```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 `app_baseURL` would be `https:///`.

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 `app_baseURL`, all for runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_HOST=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r .internal_hostname)
DB_PORT=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r .internal_port)
for pair in "DB_HOST=$DB_HOST" "DB_PORT=$DB_PORT" "DB_DATABASE=storefront" "DB_USERNAME=storefront" \
  "DB_PASSWORD=$DB_PASSWORD" "DB_URL=postgres://storefront:$DB_PASSWORD@$DB_HOST:$DB_PORT/storefront" \
  "app_baseURL=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 7 keys as it's added. The build reads none of them.

CLI: `sevalla apps env-vars create $APP_ID --key app_baseURL --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 migration 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: "Migrate", type: "job", entrypoint: "php spark migrate --all", 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":"Migrate","job_start_policy":"beforeDeployment"}
```

CLI: `sevalla apps processes create $APP_ID --name Migrate --type job --resource-type $H1_ID --command "php spark migrate --all" --job-start-policy beforeDeployment`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**9. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg branch "$BRANCH" '{branch: $branch}')" | jq -r .id)
```

CLI: `sevalla apps deployments trigger $APP_ID --branch $BRANCH`. MCP: `trigger_deployment`.

**10. Wait for the deployment**

Poll [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until the status is final: `success`, `failed`, `cancelled` or `skipped`. The loop keeps polling through a failed request and stops after 30 minutes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "$H" | jq -r '.status // empty')
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

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

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

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

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