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

> Deploy a Wagtail 8.0 site to Sevalla on Django 6.1: PostgreSQL, images and documents in object storage, migrations in a job, and the admin over HTTPS.

A Wagtail site runs on Sevalla as a Django application built from your Git repository: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), Sevalla's builder, installs its dependencies, collects its static files during the build and starts it with Gunicorn in the web process, WhiteNoise serves the static files, and a job runs the migrations before each deployment. Pages live in a Sevalla PostgreSQL database, and the images and documents that editors upload live in a Sevalla [object storage](https://docs.sevalla.com/object-storage/overview.md) bucket, so every instance and every deployment sees them.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Python 3.14, pip), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `gunicorn -c /etc/ingot/gunicorn.conf.py storefront.wsgi:application`, which Ingot detects from Django and Gunicorn |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`python manage.py migrate --noinput`, **Before deployment**) |
| Services | PostgreSQL 18 over an internal connection; an object storage bucket with public access, `storefront-media` |
| Disk | None |
| Size | **H1** for the web process and the job, **DB1** for the database. **S1** for the web process when editors upload large photos |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DJANGO_SETTINGS_MODULE` | Yes | Runtime | No | `storefront.settings.production`. Runtime only: see [Choose the settings module](#choose-the-settings-module) |
| `SECRET_KEY` | Yes | Runtime | Yes | 50 or more random characters. Generate with `python3 -c "import secrets; print(secrets.token_urlsafe(50))"` |
| `ALLOWED_HOSTS` | Yes | Runtime | No | The application's domain, such as `storefront-x7k2m.sevalla.app`. Separate several hosts with commas; the first one is the admin's base URL |
| `DB_URL` | Yes | Runtime | Yes | Added by the internal connection to `orders-db`: `postgres://<user>:<password>@<host>:<port>/<database>` |
| `S3_BUCKET` | Yes | Runtime | No | The bucket's **Bucket name**, such as `storefront-media-q8w2e` |
| `S3_ENDPOINT` | Yes | Runtime | No | The bucket's **Endpoint**: `https://<account-id>.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | Yes | Runtime | Yes | The bucket's **Access key** |
| `S3_SECRET_ACCESS_KEY` | Yes | Runtime | Yes | The bucket's **Secret key** |
| `S3_PUBLIC_DOMAIN` | Yes | Runtime | No | The bucket's **Public domain**, such as `storefront-media-q8w2e.sevalla.storage` |
| `WEB_CONCURRENCY` | No | Runtime | No | Gunicorn worker processes, `1` by default. Raise it on sizes larger than **H1** |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

The internal connection also adds `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME` and `DB_PASSWORD`, which the site doesn't read.

## 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).
* Python 3.14 on your computer. Wagtail 8.0 supports Python 3.10 to 3.14, and Django 6.1 needs 3.12 or later.

## Create the project

Create a virtual environment, install Wagtail and the packages the site needs in production, and start a project named `storefront` in the current folder:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mkdir storefront && cd storefront
python3 -m venv .venv
source .venv/bin/activate
pip install wagtail django-environ gunicorn whitenoise "psycopg[binary]" "django-storages[s3]"
wagtail start storefront .
rm Dockerfile .dockerignore
```

| Package | What it does |
| - | - |
| `wagtail` | Wagtail 8.0, with Django 6.1 |
| `django-environ` | Reads settings from environment variables and database URLs |
| `gunicorn` | Production WSGI server. Ingot starts the web process with it when it's installed. |
| `whitenoise` | Serves static files, the admin's included, from the application |
| `psycopg[binary]` | PostgreSQL driver, with its own copy of the PostgreSQL client library |
| `django-storages[s3]` | Saves uploaded images and documents to the bucket through its S3-compatible API, with boto3 |

`wagtail start` writes the project: the `home` and `search` apps, settings split into `storefront/settings/base.py`, `dev.py` and `production.py`, and a `Dockerfile` with its `.dockerignore`. The `rm` removes those two: the template's `.dockerignore` lists `.python-version`, which hides the Python version from Ingot, and its `Dockerfile` runs the migrations each time a container starts. [Deploy with a Dockerfile](#deploy-with-a-dockerfile) gives a replacement.

Keep local files out of the repository:

```text .gitignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.venv/
__pycache__/
.env
db.sqlite3
/static/
/media/
```

Create the local SQLite database, an admin user, and start the development server:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
```

Open `http://127.0.0.1:8000`: the page says **Welcome to your new Wagtail site!** The admin is at `http://127.0.0.1:8000/admin/`. Press `Ctrl+C` to stop the server. `manage.py` and `storefront/wsgi.py` load `storefront.settings.dev` unless `DJANGO_SETTINGS_MODULE` says otherwise, so your computer runs with debug mode on and SQLite.

## Prepare the project for Sevalla

### Serve static files with WhiteNoise

In `storefront/settings/base.py`, add WhiteNoise right after `SecurityMiddleware`:

```python storefront/settings/base.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    # ...the rest of the middleware...
]
```

In the same file, change the `staticfiles` storage to WhiteNoise's:

```python storefront/settings/base.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
STORAGES = {
    "default": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
    },
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}
```

WhiteNoise compresses the static files and gives them hashed names, so browsers and the [Sevalla CDN](https://docs.sevalla.com/applications/caching.md) can cache them for a long time. The storage goes in `base.py`, not `production.py`, because the build collects the static files with the dev settings: see [How Ingot builds and starts the project](#how-ingot-builds-and-starts-the-project).

### Read production settings from environment variables

Replace `storefront/settings/production.py` with settings that come from the environment:

```python storefront/settings/production.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import environ

from .base import *

env = environ.Env()

DEBUG = False
SECRET_KEY = env("SECRET_KEY")
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
WAGTAILADMIN_BASE_URL = f"https://{ALLOWED_HOSTS[0]}"

DATABASES = {"default": env.db("DB_URL")}
# Wagtail's search indexes on PostgreSQL need it.
INSTALLED_APPS += ["django.contrib.postgres"]

STORAGES["default"] = {
    "BACKEND": "storages.backends.s3.S3Storage",
    "OPTIONS": {
        "bucket_name": env("S3_BUCKET"),
        "endpoint_url": env("S3_ENDPOINT"),
        "access_key": env("S3_ACCESS_KEY_ID"),
        "secret_key": env("S3_SECRET_ACCESS_KEY"),
        "region_name": "auto",
        "signature_version": "s3v4",
        "custom_domain": env("S3_PUBLIC_DOMAIN"),
        "querystring_auth": False,
        "file_overwrite": False,
    },
}
```

* `DB_URL` is the variable Sevalla adds when you connect the database. django-environ accepts the `postgres://` URL that Sevalla generates.
* `django.contrib.postgres` gives Wagtail's database search backend the PostgreSQL full-text indexes it creates. Without it, the migrations stop with `(postgres.E005) 'django.contrib.postgres' must be in INSTALLED_APPS in order to use GinIndex.`
* `WAGTAILADMIN_BASE_URL` is the address Wagtail puts in notification emails and in links from the admin. It's the first host in `ALLOWED_HOSTS`, over HTTPS.
* The `default` storage saves every image, rendition and document that editors upload to the bucket. `custom_domain` and `querystring_auth` make their URLs permanent links on the bucket's public domain, which pages can embed and browsers can cache. `file_overwrite` keeps two uploads with the same name apart.
* Django sees HTTPS requests as secure without `SECURE_PROXY_SSL_HEADER`: Ingot's image trusts the `X-Forwarded-Proto` header that Sevalla's proxy sets (see [proxy headers](https://docs.sevalla.com/guides/python/overview.md#how-python-applications-run-on-sevalla)), so signing in to the admin passes Django's CSRF check without `CSRF_TRUSTED_ORIGINS`.

### Choose the settings module

On Sevalla, set the `DJANGO_SETTINGS_MODULE` environment variable to `storefront.settings.production`, **Available during runtime** only. The web process, the `Migrate` job and the [web terminal](https://docs.sevalla.com/applications/web-terminal.md) then load the production settings. Without the variable, they load the dev settings, and the job migrates a SQLite file inside the container instead of PostgreSQL.

Keep it out of the build. The build runs `collectstatic`, which only needs the dev settings, and with the production settings it would need the secret key, the database and the bucket's keys during the build too.

### Pin the Python version

Ingot builds on Python 3.14, its default line, unless the project sets a version. Pin 3.14 in `.python-version`, so the build doesn't change when a new Ingot release moves its default line:

```text .python-version theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
3.14
```

See [Pin the Python version](https://docs.sevalla.com/guides/python/overview.md#pin-the-python-version).

### List the dependencies

Ingot installs `requirements.txt` with pip, into a virtual environment at `/app/.venv` that's on the `PATH`. `wagtail start` wrote one with version ranges; replace it with the exact versions you tested:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pip freeze > requirements.txt
```

### How Ingot builds and starts the project

The project needs no build or start configuration. Ingot finds `manage.py` and Django in `requirements.txt`, and then:

* Installs the dependencies and runs `python manage.py collectstatic --noinput`, which collects Wagtail's admin files and your own into `/app/static`, part of the image. With `DJANGO_SETTINGS_MODULE` available during runtime only, `collectstatic` loads the dev settings, which read no environment variables. The build plan still warns `Django's collectstatic runs during the build, and the settings read environment variables without a default`, because Ingot reads `production.py` too; the build passes.
* Starts the web process with `gunicorn -c /etc/ingot/gunicorn.conf.py storefront.wsgi:application`. Gunicorn listens on the port in the `PORT` environment variable, which Sevalla sets to the web process's port (`8080` by default).
* Doesn't run migrations. A [job](https://docs.sevalla.com/applications/processes.md#jobs) runs them once per deployment, before the new version starts.

Gunicorn runs 1 worker process, and Ingot's Gunicorn settings give it 4 threads, which fits the smallest size, **H1** (0.3 GB of memory). On a larger size, set the `WEB_CONCURRENCY` environment variable to the number of worker processes, for example `2`. To run another server or other options, see [Set the start command](https://docs.sevalla.com/applications/builds/ingot.md#set-the-start-command).

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**, choose **PostgreSQL** and the newest version. Enter `orders-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 bucket**

In the sidebar, click **Object storage**, then click **Create object storage**. Enter `storefront-media` as the **Name**, choose the **Location** closest to the application, and click **Create object storage**. On the bucket's **Settings** tab, click **Enable public access** under **Public access**, then click **Enable public access** in the dialog. See [Connect with the S3 API](https://docs.sevalla.com/object-storage/connect.md).

**3. 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.

**4. Connect the database**

Click the **Networking** tab. Under **Connected services**, click **Add internal connection**. Choose `orders-db` as the **Service**, turn on **Add environment variables to the application**, and click **Add internal connection**. Sevalla adds `DB_URL` and the other connection variables to the application. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

**5. Add the environment variables**

Click the **Environment variables** tab, then click **Add environment variables**. Add these variables, keep **Available during runtime** selected, clear **Available during buildtime**, and click **Add environment variables**:

| Variable | Value |
| - | - |
| `DJANGO_SETTINGS_MODULE` | `storefront.settings.production` |
| `SECRET_KEY` | A random string of 50 characters or more. Generate one with `python3 -c "import secrets; print(secrets.token_urlsafe(50))"`. |
| `ALLOWED_HOSTS` | The application's domain without the scheme, for example `storefront-x7k2m.sevalla.app`. It's under the application's name in the page header. |
| `S3_BUCKET` | **Bucket name** under **Details** on the bucket's **Settings** tab. It's not the display name. |
| `S3_ENDPOINT` | **Endpoint** under **Details** |
| `S3_ACCESS_KEY_ID` | **Access key** under **Access keys** |
| `S3_SECRET_ACCESS_KEY` | **Secret key** under **Access keys** |
| `S3_PUBLIC_DOMAIN` | **Public domain** under **Public access** |

**6. Create the migration job**

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

**7. Deploy**

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

**8. Turn on auto-deploy**

To deploy every push to the branch automatically, click the **Settings** tab, then click **Update auto-deploy** in the **Auto-deploy** section. Turn on **Auto-deploy** and click **Update auto-deploy**. See [Deployments](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push).

## Verify the deployment

1. On the **Deployments** tab, wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**. Open the deployment to read its build log. Ingot's build plan comes first:

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Django · Python 3.14 ─────────────────────────────────────────────────────────────────────────────────────╮
   │ Python           3.14                                                                 .python-version "3.14" │
   │ Package manager  pip                                                                  requirements.txt       │
   │ Framework        Django                                                               manage.py              │
   │ Start            gunicorn -c /etc/ingot/gunicorn.conf.py storefront.wsgi:application  Django                 │
   ├─ Image ──────────────────────────────────────────────────────────────────────────────────────────────────────┤
   │ Build            python:3.14-slim-trixie                                                                     │
   │ Runtime          python:3.14-slim-trixie                                                                     │
   │ User             1000:1000                                                                                   │
   │ Init             tini                                                                                        │
   │ Port             $PORT, default 8080                                                                         │
   ╰─────────────────────────────────────────────────────────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

   Under the plan, the log shows the warning that [How Ingot builds and starts the project](#how-ingot-builds-and-starts-the-project) explains. Later, `collectstatic` reports `217 static files copied to '/app/static', 631 post-processed.`, and, during the rollout, `Migrate has been successful`. If the job fails, the deployment fails and nothing changes for visitors. See [Read the build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan).

   With the API, [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns the status, `success` for **Deployed**, and [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) returns the build log, which `search` filters. `APP_ID` and `DEPLOYMENT_ID` come from [Deploy with the API](#deploy-with-the-api):

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID \
     -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .status
   curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?search=Migrate%20has%20been" \
     -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   success
   ✅ Migrate has been successful
   ```

   The log's messages carry terminal color codes around the text.
2. Click the domain under the application's name, for example `storefront-x7k2m.sevalla.app`. The page says **Welcome to your new Wagtail site!**, with its styles, which shows that the site reads PostgreSQL, where the migrations created the home page, and that WhiteNoise serves the static files. From a shell, with your application's domain:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://storefront-x7k2m.sevalla.app/ | grep -o 'Welcome to your new Wagtail site!'
   curl -s https://storefront-x7k2m.sevalla.app/admin/login/ | grep -o '<title>.*</title>'
   curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://storefront-x7k2m.sevalla.app/static/wagtailadmin/css/core.css
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Welcome to your new Wagtail site!
   <title>Sign in - Wagtail</title>
   200 text/css; charset="utf-8"
   ```

If the deployment fails, read its build log, then see [Troubleshoot](#troubleshoot) and [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds).

## Sign in to the admin

The database starts without users. Create the first admin: click the **Web terminal** tab, and in the shell that opens in the **Web** process, run:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
python manage.py createsuperuser
```

It asks for a username, an email address and a password. The terminal has the application's environment variables, so the command uses the production settings and PostgreSQL. See [Web terminal](https://docs.sevalla.com/applications/web-terminal.md).

Then open `/admin/` on the application's domain and sign in. The admin opens on its dashboard. The sign-in form posts over HTTPS through Sevalla's proxy, and Django's CSRF check accepts it, because Gunicorn passes on the `https` scheme from the `X-Forwarded-Proto` header.

A script or an AI agent can create the user without a terminal with [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process) (`execProcessCommandV3`), which runs a command in a running instance. `createsuperuser --noinput` reads the password from `DJANGO_SUPERUSER_PASSWORD`. `APP_ID` and `WEB_ID` come from [Deploy with the API](#deploy-with-the-api):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
ADMIN_PASSWORD=$(openssl rand -hex 16)
curl -s -X POST https://api.sevalla.com/v3/applications/$APP_ID/processes/$WEB_ID/exec \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"command":["env","DJANGO_SUPERUSER_PASSWORD='"$ADMIN_PASSWORD"'","python","manage.py","createsuperuser","--noinput","--username","admin","--email","admin@example.com"],"timeout":60}' \
  | jq -c .
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"stdout":"Superuser created successfully.\n","stderr":"","exit_code":0}
```

CLI: `sevalla apps processes exec $WEB_ID --app-id $APP_ID -- env DJANGO_SUPERUSER_PASSWORD=$ADMIN_PASSWORD python manage.py createsuperuser --noinput --username admin --email admin@example.com`. MCP: `sevalla_api_update` with `execProcessCommandV3`.

Sign in the way a browser does, with the CSRF token from the sign-in page and an `Origin` header, then open the dashboard:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
JAR=$(mktemp)
TOKEN=$(curl -s -c $JAR https://storefront-x7k2m.sevalla.app/admin/login/ \
  | grep -o 'name="csrfmiddlewaretoken" value="[^"]*"' | cut -d'"' -f4)
curl -s -b $JAR -c $JAR -o /dev/null -w '%{http_code} %{redirect_url}\n' https://storefront-x7k2m.sevalla.app/admin/login/ \
  -H "Origin: https://storefront-x7k2m.sevalla.app" -H "Referer: https://storefront-x7k2m.sevalla.app/admin/login/" \
  --data-urlencode "csrfmiddlewaretoken=$TOKEN" --data-urlencode "username=admin" --data-urlencode "password=$ADMIN_PASSWORD"
curl -s -b $JAR https://storefront-x7k2m.sevalla.app/admin/ | grep -o '<title>.*</title>'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
302 https://storefront-x7k2m.sevalla.app/admin/
<title>Dashboard - Wagtail</title>
```

## Upload images and documents

In the admin, click **Images**, then **Add an image**, and upload a picture. Wagtail saves the original under `original_images/` in the bucket, and each size that a template asks for, a rendition, under `images/`. In the sidebar of the Sevalla dashboard, click **Object storage**, click the bucket, and the **Objects** tab lists both files. **Documents** works the same way, under `documents/`, and a document's link on the site redirects to the file on the bucket's public domain. See [Manage objects](https://docs.sevalla.com/object-storage/objects.md).

Public access applies to the whole bucket: anyone who has a file's URL can download it, documents in private collections included.

Wagtail resizes images in the web process, so large photos need memory. On **H1** (0.3 GB), uploading a 12-megapixel photo took the web process to about 250 MB, and a second Python process in the same instance, such as `manage.py shell` in the web terminal, ran out of memory resizing one. When editors upload photos, choose **S1** (1 GB) or larger for the web process; see [Scaling](https://docs.sevalla.com/applications/scaling.md).

A script or an AI agent can check the storage with [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process) (`execProcessCommandV3`): the command saves a 640 by 480 PNG as a Wagtail image and prints the URL of a 320-pixel-wide rendition. Then it downloads the rendition, and [List objects](https://api-docs.sevalla.com/v3/object-storages/list-objects) (`listObjectStorageObjectsV3`) lists the bucket. `APP_ID`, `WEB_ID` and `BUCKET_ID` come from [Deploy with the API](#deploy-with-the-api):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
IMAGE_URL=$(curl -s -X POST https://api.sevalla.com/v3/applications/$APP_ID/processes/$WEB_ID/exec \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"command":["python","manage.py","shell","-v","0","-c","from io import BytesIO; from PIL import Image as Picture; from django.core.files.images import ImageFile; from wagtail.images.models import Image; data = BytesIO(); Picture.new(\"RGB\", (640, 480), \"teal\").save(data, \"PNG\"); image = Image.objects.create(title=\"Coffee mug\", file=ImageFile(data, name=\"mug.png\")); print(image.get_rendition(\"width-320\").url)"],"timeout":60}' \
  | jq -r .stdout)
echo "$IMAGE_URL"
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' "$IMAGE_URL"
curl -s https://api.sevalla.com/v3/object-storage/$BUCKET_ID/objects \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.data[].key'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
https://storefront-media-q8w2e.sevalla.storage/images/mug.width-320.png
200 image/png
images/mug.width-320.png
original_images/mug.png
```

CLI: `sevalla apps processes exec` and `sevalla object-storage objects list --bucket-id $BUCKET_ID`. MCP: `sevalla_api_update` with `execProcessCommandV3`, and `sevalla_api_read` with `listObjectStorageObjectsV3`.

## Deploy with a Dockerfile

Ingot needs no Dockerfile. Write your own when you want full control of the image: the base image, the system packages and every layer. It replaces the `Dockerfile` that `wagtail start` wrote. Add a `Dockerfile` and a `.dockerignore` to the root of the repository:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FROM python:3.14-slim-trixie

ENV PYTHONUNBUFFERED=1 \
    PORT=8080 \
    FORWARDED_ALLOW_IPS="*"

RUN useradd --uid 1000 --create-home app
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --root-user-action=ignore -r requirements.txt

COPY . .
RUN python manage.py collectstatic --noinput \
    && python -m compileall -q .

ENV DJANGO_SETTINGS_MODULE=storefront.settings.production
USER 1000:1000
EXPOSE 8080
CMD ["gunicorn", "storefront.wsgi:application", \
     "--threads", "4", "--keep-alive", "0", \
     "--graceful-timeout", "20", "--worker-tmp-dir", "/dev/shm"]
```

| Line or choice | Why |
| - | - |
| `python:3.14-slim-trixie` | The Python line that `.python-version` pins, on Debian 13, as Ingot builds it. A line tag picks up each patch release and security fix when you deploy again; a digest would stay on one release. |
| One stage, no system packages | pip installs prebuilt wheels: Pillow, `pillow-heif` and `psycopg[binary]` bring their own image and PostgreSQL libraries, so the build needs no compiler or `apt-get`. |
| `requirements.txt` before the code | Docker reuses the installed packages until `requirements.txt` changes, so a code change rebuilds only the last layers. |
| `collectstatic` before `DJANGO_SETTINGS_MODULE` | `collectstatic` runs with the dev settings, as on Ingot, so the build needs no secret key, database or bucket keys, and none of them enters the image. The `ENV` line after it makes every container load the production settings. |
| `compileall` | Compiles the code at build time, because the site can't write to `/app` when it runs. |
| `useradd` and `USER 1000:1000` | Runs Gunicorn as an unprivileged user with a home directory, where Gunicorn keeps its control socket. The code and static files stay owned by root, so the site can't change them. |
| `FORWARDED_ALLOW_IPS="*"` | Gunicorn trusts the `X-Forwarded-Proto` header from Sevalla's proxy, so Django sees HTTPS requests as secure and signing in to the admin passes the CSRF check. Ingot sets the same. |
| Gunicorn options | Gunicorn listens on `0.0.0.0` and the port in `PORT` on its own, and reads `WEB_CONCURRENCY`. `--threads 4`, `--graceful-timeout 20` and `--worker-tmp-dir /dev/shm` match Ingot's settings. `--keep-alive 0` closes each connection after its response: otherwise Gunicorn 26.2 waits out the whole graceful timeout before it stops when the proxy holds an idle connection open. |
| No init process | Gunicorn runs as PID 1, handles `SIGTERM` itself, finishes the requests in progress and exits. |

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.venv
**/__pycache__
.env*
db.sqlite3
/static
/media
Dockerfile
.dockerignore
```

Commit and push both files. Ingot doesn't use the `Dockerfile`, so you can keep it in a repository that builds with Ingot, but it adds the `.dockerignore` patterns to its own: its build plan shows an **Ignored** row with `9 patterns from .dockerignore`. 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 Update build strategy sheet with Dockerfile selected, the Dockerfile path, the Docker context and the Docker registry credential](https://assets.sevalla.com/screenshots/light/app-settings-build-strategy-dockerfile.png)

The build log shows `📦 Building and pushing docker image using Dockerfile...` where Ingot's build plan was, then `217 static files copied to '/app/static', 631 post-processed.` and `Migrate has been successful`: the job runs `python manage.py migrate --noinput` in the new image. The checks in [Verify the deployment](#verify-the-deployment) and [Sign in to the admin](#sign-in-to-the-admin) print the same output, uploads still go to the bucket, and the deployment's **Dockerfile** tab shows your Dockerfile. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md), and [Build from the Dockerfile with the API](#build-from-the-dockerfile-with-the-api) to switch with the API.

## 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. When you change a page model, run `python manage.py makemigrations` on your computer and commit the new migration with the code. When you add a package, run `pip freeze > requirements.txt` again and commit it. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Add a custom domain

[Add the domain](https://docs.sevalla.com/domains/overview.md) on the application's **Domains** tab, then put it first in `ALLOWED_HOSTS`, for example `shop.example.com,storefront-x7k2m.sevalla.app`, so that `WAGTAILADMIN_BASE_URL` uses it, and deploy. In the Wagtail admin, click **Settings** > **Sites**, open the site, set **Hostname** to `shop.example.com` and **Port** to `443`, and save: Wagtail builds the full URLs of pages, such as in its sitemap, from them. Custom domains need a web process larger than **H1**.

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| The build plan's **Python** row shows `3.14` with the source `default`, and the build log warns `No Python version is declared` | The `.dockerignore` from `wagtail start` lists `.python-version`, and Ingot adds its patterns to its own. | Delete the template's `Dockerfile` and `.dockerignore`, or remove `.python-version` from the `.dockerignore`, then push. |
| The build fails in `collectstatic` with `django.core.exceptions.ImproperlyConfigured: Set the ALLOWED_HOSTS environment variable`, or another variable | `DJANGO_SETTINGS_MODULE` is available during the build, so `collectstatic` loads the production settings, which read variables that are available during runtime only. | Update `DJANGO_SETTINGS_MODULE` and clear **Available during buildtime**, then deploy. |
| The deployment fails at `Migrate has failed`, and the job's output shows `(postgres.E005) 'django.contrib.postgres' must be in INSTALLED_APPS in order to use GinIndex.` | Wagtail's search indexes on PostgreSQL need `django.contrib.postgres`. | Add it to `INSTALLED_APPS` in `production.py`, as in [Read production settings from environment variables](#read-production-settings-from-environment-variables). |
| The build log shows `Migrate has been successful`, but every page returns `500` with Django's debug page, `OperationalError at /` and `no such table: wagtailcore_site`. | `DJANGO_SETTINGS_MODULE` isn't set at runtime, so the job and the web process load the dev settings, each with its own SQLite file. | Add `DJANGO_SETTINGS_MODULE` with the value `storefront.settings.production`, **Available during runtime**, then deploy. |
| Every page returns `Bad Request (400)` | The domain isn't in `ALLOWED_HOSTS`. | Add the domain to `ALLOWED_HOSTS`, then deploy. |
| With your own Dockerfile, signing in to the admin fails with `CSRF verification failed. Request aborted.` | Gunicorn doesn't trust the `X-Forwarded-Proto` header from Sevalla's proxy, so Django sees the HTTPS form post as HTTP and rejects its origin. | Set `FORWARDED_ALLOW_IPS="*"` in the Dockerfile, as in [Deploy with a Dockerfile](#deploy-with-a-dockerfile). |
| A command in the web terminal that resizes a large image stops with exit code `137`, and the web process restarts | The instance ran out of memory: the web terminal's process shares the web process's memory. | Choose a larger size for the web process, such as **S1** (1 GB). |

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). For more on media files, see [Store Django media in object storage](https://docs.sevalla.com/guides/python/django/object-storage.md).

## Deploy with the API

These calls make the same deployment as [Deploy to Sevalla](#deploy-to-sevalla), in an order that a script or an AI agent runs as it is. Each step names the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) operation, then the [CLI](https://docs.sevalla.com/developer-tools/cli.md) command and the [MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool that do the same. You need:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role in the `SEVALLA_API_KEY` environment variable. The CLI reads the key from `SEVALLA_API_TOKEN`.
* The repository on a Git provider that's connected to your Sevalla account. See [Git providers](https://docs.sevalla.com/git/overview.md). Creating the application checks it: when Sevalla can't read the repository, the call returns `400` with `Cannot access repository https://github.com/acme/storefront: Repository not found or you don't have access.`, followed by where to grant access.
* `curl`, `jq`, `openssl` and `python3`.

The sequence creates a database, a bucket and an application. The API allows 20 creations of each per hour for a company.

**1. Choose the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns the locations your company can use, each with its `id`, the `display_name` that the dashboard shows and its region ID in `location`. Keep the ID of the one you want by its region ID, such as `eu-frankfurt-1` for Frankfurt, Germany. [Locations](https://docs.sevalla.com/platform/locations.md) lists them all:

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

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

**2. Find the sizes**

[List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) and [List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`) return the sizes by `name`, in lowercase. Keep **H1** for the processes and **DB1**, the smallest, for the database:

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

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

**3. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) starts creating a PostgreSQL 18 database. You choose the password here, because the API never returns it and `DB_URL` needs it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_PASSWORD=$(openssl rand -hex 16)
DB_ID=$(curl -s -X POST https://api.sevalla.com/v3/databases \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"display_name":"orders-db","type":"postgresql","version":"18","cluster_id":"'"$CLUSTER_ID"'","resource_type_id":"'"$DB1_ID"'","db_name":"storefront","db_user":"storefront","db_password":"'"$DB_PASSWORD"'"}' \
  | jq -r .id)
```

CLI: `sevalla databases create --name orders-db --type postgresql --db-version 18 --cluster $CLUSTER_ID --resource-type $DB1_ID --db-name storefront --db-user storefront --db-password $DB_PASSWORD`. MCP: `create_database`, which generates a password it never returns. Call it once the application exists, with `connect_to_application_id` set to the application and `env_var_name` set to `DB_URL`: it also adds the internal connection and `DB_URL`, as a `postgresql://` URL that django-environ accepts too, in place of the **Connect the database** step and the `DB_URL` line of **Add the environment variables**.

**4. Create the bucket**

[Create object storage bucket](https://api-docs.sevalla.com/v3/object-storages/create-object-storage-bucket) (`createObjectStorageV3`) creates a private bucket. `location` is where most requests come from, here `weur` (Western Europe) for a site in Frankfurt. The response is the only one that contains the access key and the secret key, so keep it in a shell variable and don't print it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
BUCKET=$(curl -s -X POST https://api.sevalla.com/v3/object-storage \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"display_name":"storefront-media","location":"weur"}')
BUCKET_ID=$(echo "$BUCKET" | jq -r .id)
echo "$BUCKET" | jq -r .bucket_name
```

It prints the bucket name, such as `storefront-media-q8w2e`. CLI: `sevalla object-storage create --name storefront-media --location weur`. MCP: `sevalla_api_create` with `createObjectStorageV3` and the same body.

**5. Turn on public access**

[Enable CDN domain](https://api-docs.sevalla.com/v3/object-storages/enable-cdn-domain) (`createObjectStorageDomainV3`) turns on public access, as **Enable public access** does in the dashboard, and returns the public domain:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
S3_PUBLIC_DOMAIN=$(curl -s -X POST https://api.sevalla.com/v3/object-storage/$BUCKET_ID/domain \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .domain)
echo "$S3_PUBLIC_DOMAIN"
```

It prints the domain, such as `storefront-media-q8w2e.sevalla.storage`. CLI: `sevalla object-storage cdn-domain enable $BUCKET_ID`. MCP: `sevalla_api_create` with `createObjectStorageDomainV3`.

**6. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates the application with a web process and auto-deploy off. It builds with Ingot (`build_type` is `ingot`) and isn't deployed yet:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(curl -s -X POST https://api.sevalla.com/v3/applications \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"display_name":"storefront","cluster_id":"'"$CLUSTER_ID"'","source":"privateGit","git_type":"github","repo_url":"https://github.com/acme/storefront","default_branch":"main"}' \
  | jq -r .id)
```

CLI: `sevalla apps create --name storefront --cluster $CLUSTER_ID --source privateGit --git-type github --repository https://github.com/acme/storefront --branch main`. MCP: `create_application` with `deploy` set to `false`; by default it starts a deployment before the variables and the job exist.

**7. Find the domain**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the domain that Sevalla gives the application, which `ALLOWED_HOSTS` needs. It appears a few seconds after the application is created, so ask until it's there:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 24); do
  HOST=$(curl -s https://api.sevalla.com/v3/applications/$APP_ID/domains \
    -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.data[]? | select(.is_primary) | .name')
  [ -n "$HOST" ] && break
  sleep 5
done
echo "$HOST"
```

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

CLI: `sevalla apps domains list --app-id $APP_ID`. MCP: `get_application`.

**8. Size the web process**

The API creates the web process at **S1**, where the dashboard's default is **H1**. [List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) finds the web process, 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 https://api.sevalla.com/v3/applications/$APP_ID/processes \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.data[] | select(.type == "web") | .id')
curl -s -X PATCH https://api.sevalla.com/v3/applications/$APP_ID/processes/$WEB_ID \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"resource_type_id":"'"$H1_ID"'"}' | jq -r .resource_type_name
```

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

CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID`. MCP: `scale_process` with `size_id` and `apply_now` set to `false`.

**9. Wait for the database**

[Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) until its `status` is `ready`, for up to 10 minutes, then keep its internal hostname and port:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 60); do
  DB_STATUS=$(curl -s https://api.sevalla.com/v3/databases/$DB_ID \
    -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.status // empty')
  [ "$DB_STATUS" = ready ] && break
  sleep 10
done
DB=$(curl -s https://api.sevalla.com/v3/databases/$DB_ID -H "Authorization: Bearer $SEVALLA_API_KEY")
DB_HOST=$(echo "$DB" | jq -r .internal_hostname)
DB_PORT=$(echo "$DB" | jq -r .internal_port)
echo "$DB_STATUS"
```

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

CLI: `sevalla databases get $DB_ID --json`. MCP: `get_database`.

**10. Connect the database**

[Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`) lets the application reach the database on Sevalla's private network, and returns the connection's `id`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST https://api.sevalla.com/v3/applications/$APP_ID/internal-connections \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target_id":"'"$DB_ID"'","target_type":"database"}' | jq -r .id
```

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.

**11. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per variable, each available during runtime only. Unlike the dashboard, the API doesn't add the connection's variables, so `DB_URL` is built here in the dashboard's format:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
add_var() {
  curl -s -X POST https://api.sevalla.com/v3/applications/$APP_ID/env-vars \
    -H "Authorization: Bearer $SEVALLA_API_KEY" \
    -H "Content-Type: application/json" \
    -d "$(jq -n --arg key "$1" --arg value "$2" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}')" \
    | jq -r .key
}
add_var DJANGO_SETTINGS_MODULE storefront.settings.production
add_var SECRET_KEY "$(python3 -c "import secrets; print(secrets.token_urlsafe(50))")"
add_var ALLOWED_HOSTS "$HOST"
add_var DB_URL "postgres://storefront:$DB_PASSWORD@$DB_HOST:$DB_PORT/storefront"
add_var S3_BUCKET "$(echo "$BUCKET" | jq -r .bucket_name)"
add_var S3_ENDPOINT "$(echo "$BUCKET" | jq -r .endpoint)"
add_var S3_ACCESS_KEY_ID "$(echo "$BUCKET" | jq -r .access_key)"
add_var S3_SECRET_ACCESS_KEY "$(echo "$BUCKET" | jq -r .secret_key)"
add_var S3_PUBLIC_DOMAIN "$S3_PUBLIC_DOMAIN"
```

It prints each key as it's added. CLI: `sevalla apps env-vars create $APP_ID --key DJANGO_SETTINGS_MODULE --value storefront.settings.production`, once per variable; the CLI makes every variable available during build and runtime. With `DJANGO_SETTINGS_MODULE` in the build, `collectstatic` loads the production settings and fails with `ImproperlyConfigured: Set the ALLOWED_HOSTS environment variable` while the other variables are runtime only, so add `DJANGO_SETTINGS_MODULE` with the API or MCP (see [Choose the settings module](#choose-the-settings-module)). MCP: `set_environment_variables` with `buildtime` set to `false`.

**12. Create the migration job**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`). A job needs `job_start_policy`, and every process needs `resource_type_id` and `scaling_strategy`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST https://api.sevalla.com/v3/applications/$APP_ID/processes \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"display_name":"Migrate","type":"job","entrypoint":"python manage.py migrate --noinput","job_start_policy":"beforeDeployment","resource_type_id":"'"$H1_ID"'","scaling_strategy":{"type":"manual","config":{"instanceCount":1}}}' \
  | jq -c '{display_name, type, job_start_policy, resource_type_name}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"display_name":"Migrate","type":"job","job_start_policy":"beforeDeployment","resource_type_name":"h1"}
```

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

**13. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) turns on automatic deployments for every push to the branch:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH https://api.sevalla.com/v3/applications/$APP_ID \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"auto_deploy":true}' | jq .auto_deploy
```

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

CLI: `sevalla apps update $APP_ID --data '{"auto_deploy":true}'`. MCP: `sevalla_api_update` with `updateApplicationV3`.

**14. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) deploys the latest commit on the branch:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST https://api.sevalla.com/v3/applications/$APP_ID/deployments \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}' | jq -r .id)
```

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

**15. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns the deployment's `status`: `waiting` and `inProgress` while it runs, then `success` or `failed`. Poll it every 15 seconds, for up to 30 minutes, until the status is final:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  STATUS=$(curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" | 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`.

**16. 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 with `collectstatic`, `Migrate has been successful` and the rollout, which ends with `Web has been deployed successfully.` When the status is `failed`, the last lines say why. Without `limit`, the call returns the latest 1,000 lines, so a longer log loses its start; `5000` is the most it allows.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?limit=5000" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'
```

CLI: `sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment_logs`.

Then run the checks in [Verify the deployment](#verify-the-deployment), [Sign in to the admin](#sign-in-to-the-admin) and [Upload images and documents](#upload-images-and-documents), with `$HOST` in place of `storefront-x7k2m.sevalla.app`.

### Build from the Dockerfile with the API

After you push the `Dockerfile` from [Deploy with a Dockerfile](#deploy-with-a-dockerfile), [Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) switches the build strategy. Then repeat the **Deploy** and **Wait for the deployment** steps:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH https://api.sevalla.com/v3/applications/$APP_ID \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}' \
  | jq -c '{build_type, dockerfile_path, docker_context}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}
```

CLI: `sevalla apps update $APP_ID --data '{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}'`. MCP: `sevalla_api_update` with `updateApplicationV3`.


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