> ## 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 Dify

> Self-host Dify on Sevalla from its official images, with PostgreSQL and pgvector, Valkey, object storage, a working code sandbox and a gateway, then run a workflow that calls Claude.

Dify is an open-source platform for building AI applications: chatbots, agents and workflows that you design in a web interface and call over an API. On Sevalla, Dify runs as five applications: four from Dify's official Docker images (the API with its background workers, the web interface, the code sandbox and the plugin daemon) and a small nginx gateway you build from a Dockerfile, which is the only one with a public domain. A PostgreSQL database with pgvector stores Dify's data and the knowledge base vectors, Valkey holds the cache and the task queue, and an object storage bucket keeps uploaded files and plugins, so no application needs a disk.

The sandbox, which runs the code in Dify's Code nodes, works without privileged containers: it isolates code with seccomp and `chroot`, which Sevalla's containers allow. This guide was tested with Dify 1.18.0, and ends with a workflow that runs Python in the sandbox and calls Claude Haiku 5.5.

## At a glance

| | |
| - | - |
| Source | The `langgenius/dify-api:1.18.0`, `langgenius/dify-web:1.18.0`, `langgenius/dify-sandbox:0.2.15` and `langgenius/dify-plugin-daemon:0.6.10-local` Docker images, and a Git repository with the gateway's [Dockerfile](#create-the-gateway) |
| Build | None for the images; the gateway builds from its Dockerfile |
| Start command | The images' own. `dify-api` also runs `exec flask upgrade-db`, `exec env MODE=worker /bin/bash /entrypoint.sh` and `exec env MODE=beat /bin/bash /entrypoint.sh` |
| Port | `dify`, the gateway: `8080`, public. `dify-api` `5001`, `dify-web` `3000`, `dify-sandbox` `8194` and `dify-plugin-daemon` `5002`, all with HTTP traffic off |
| Processes | `dify-api`: web process, `Worker` and `Beat` background workers, `Migrate` job (**Before deployment**). The other applications: a web process each |
| Services | PostgreSQL 18 with **Enable pgvector extension**, Valkey 9.0 and an object storage bucket, over internal connections and HTTPS |
| Disk | None |
| Health check | Readiness probes: `/health` on `dify-api` and `dify-sandbox`, `/health/check` on `dify-plugin-daemon`, `/favicon.ico` on `dify-web`, `/healthz` on `dify` |
| Size | **S1** for `dify-api`'s four processes and for `dify-plugin-daemon`; **H1** for `dify-web`, `dify-sandbox` and `dify`; **DB1** for both databases |

### Environment variables

An application from a Docker image has no build, so every variable is available during runtime only. Hostnames ending in `.svc.cluster.local` are internal hostnames: each application's **Networking** tab shows its own.

`dify-api`:

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` | Yes | Runtime | `DB_PASSWORD` | From the `dify-db` internal connection. Dify reads these names as they are |
| `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` | Yes | Runtime | `REDIS_PASSWORD` | From the `dify-redis` internal connection |
| `CELERY_BROKER_URL` | Yes | Runtime | Yes | `redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/1` |
| `SECRET_KEY` | Yes | Runtime | Yes | Generate with `openssl rand -base64 42`, and keep it: see [Keep the keys](#keep-the-keys) |
| `INIT_PASSWORD` | Yes | Runtime | Yes | A password of up to 30 characters, for example from `openssl rand -hex 12`. Dify asks for it before it creates the admin account |
| `CONSOLE_WEB_URL`, `CONSOLE_API_URL`, `SERVICE_API_URL`, `APP_WEB_URL`, `FILES_URL`, `TRIGGER_URL` | Yes | Runtime | No | The gateway's URL, for example `https://dify-x7k2m.sevalla.app` |
| `INTERNAL_FILES_URL` | Yes | Runtime | No | `http://` and `dify-api`'s internal hostname with port `5001` |
| `STORAGE_TYPE` | Yes | Runtime | No | `s3` |
| `S3_ENDPOINT`, `S3_BUCKET_NAME` | Yes | Runtime | No | The bucket's **Endpoint** and **Bucket name** |
| `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Yes | Runtime | Yes | The bucket's **Access key** and **Secret key** |
| `S3_REGION` | Yes | Runtime | No | `auto` |
| `VECTOR_STORE` | Yes | Runtime | No | `pgvector` |
| `PGVECTOR_HOST`, `PGVECTOR_PORT`, `PGVECTOR_USER`, `PGVECTOR_PASSWORD`, `PGVECTOR_DATABASE` | Yes | Runtime | `PGVECTOR_PASSWORD` | `${DB_HOST}`, `${DB_PORT}`, `${DB_USERNAME}`, `${DB_PASSWORD}`, `${DB_DATABASE}` |
| `CODE_EXECUTION_ENDPOINT` | Yes | Runtime | No | `http://` and `dify-sandbox`'s internal hostname with port `8194` |
| `CODE_EXECUTION_API_KEY` | Yes | Runtime | Yes | The same value as `API_KEY` on `dify-sandbox` |
| `PLUGIN_DAEMON_URL` | Yes | Runtime | No | `http://` and `dify-plugin-daemon`'s internal hostname with port `5002` |
| `PLUGIN_DAEMON_KEY` | Yes | Runtime | Yes | The same value as `SERVER_KEY` on `dify-plugin-daemon`; generate with `openssl rand -hex 32` |
| `INNER_API_KEY_FOR_PLUGIN` | Yes | Runtime | Yes | The same value as `DIFY_INNER_API_KEY` on `dify-plugin-daemon`; generate with `openssl rand -hex 32` |
| `ENABLE_COLLABORATION_MODE` | Yes | Runtime | No | `false`: see [What this setup leaves out](#what-this-setup-leaves-out) |
| `RESPECT_XFORWARD_HEADERS_ENABLED` | Yes | Runtime | No | `true`, so Dify records each visitor's address from the gateway |

`dify-plugin-daemon`:

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` | Yes | Runtime | `DB_PASSWORD` | From the `dify-db` internal connection: the same database as `dify-api` |
| `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` | Yes | Runtime | `REDIS_PASSWORD` | From the `dify-redis` internal connection |
| `SERVER_KEY` | Yes | Runtime | Yes | The value of `PLUGIN_DAEMON_KEY` |
| `DIFY_INNER_API_URL` | Yes | Runtime | No | `http://` and `dify-api`'s internal hostname with port `5001` |
| `DIFY_INNER_API_KEY` | Yes | Runtime | Yes | The value of `INNER_API_KEY_FOR_PLUGIN` |
| `PLUGIN_WORKING_PATH` | Yes | Runtime | No | `/app/storage/cwd` |
| `PLUGIN_REMOTE_INSTALLING_ENABLED` | Yes | Runtime | No | `false` |
| `PLUGIN_STORAGE_TYPE` | Yes | Runtime | No | `aws_s3` |
| `PLUGIN_STORAGE_OSS_BUCKET`, `S3_ENDPOINT` | Yes | Runtime | No | The bucket's **Bucket name** and **Endpoint** |
| `AWS_ACCESS_KEY`, `AWS_SECRET_KEY` | Yes | Runtime | Yes | The bucket's **Access key** and **Secret key** |
| `S3_USE_AWS` | Yes | Runtime | No | `false` |
| `AWS_REGION` | Yes | Runtime | No | `auto` |

`dify-web`, `dify-sandbox` and `dify`:

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `CONSOLE_API_URL`, `APP_API_URL` (`dify-web`) | Yes | Runtime | No | The gateway's URL |
| `SERVER_CONSOLE_API_URL` (`dify-web`) | Yes | Runtime | No | `http://` and `dify-api`'s internal hostname with port `5001` |
| `API_KEY` (`dify-sandbox`) | Yes | Runtime | Yes | Generate with `openssl rand -hex 32` |
| `GIN_MODE` (`dify-sandbox`) | No | Runtime | No | `release` |
| `WORKER_TIMEOUT` (`dify-sandbox`) | No | Runtime | No | `15`, the seconds a Code node may run |
| `DIFY_API_HOST`, `DIFY_WEB_HOST`, `DIFY_PLUGIN_DAEMON_HOST` (`dify`) | Yes | Runtime | No | The internal hostnames of `dify-api`, `dify-web` and `dify-plugin-daemon` |

## How Dify maps onto Sevalla

Dify's own `docker-compose.yaml` runs each part as a container. This guide runs each on Sevalla as follows:

| Dify service | On Sevalla |
| - | - |
| `api` | `dify-api`, web process on port `5001` |
| `worker`, `worker_beat` | `Worker` and `Beat` background workers on `dify-api`: the same image with another start command |
| Migrations, run by `api` at startup | `Migrate` job on `dify-api`, before each deployment |
| `web` | `dify-web`, port `3000` |
| `sandbox` | `dify-sandbox`, port `8194` |
| `plugin_daemon` | `dify-plugin-daemon`, port `5002` |
| `db_postgres`, and `weaviate` as the vector store | One PostgreSQL database with **Enable pgvector extension** |
| `redis` | A Valkey database |
| The `volumes` folders | An object storage bucket |
| `nginx` | `dify`, the gateway: nginx from your repository, the only application with HTTP traffic |

The gateway gives Dify one domain, as Dify's own nginx does: it routes the API's paths (`/console/api`, `/api`, `/v1`, `/files` and others) to `dify-api`, `/e/` to `dify-plugin-daemon`, and everything else to `dify-web`. Dify's sign-in cookies are host-only (they carry the `__Host-` prefix), so the web interface and the API have to share a domain.

### What this setup leaves out

* **The SSRF proxy.** Dify's Compose file sends outgoing requests from the HTTP Request node and the sandbox through a Squid proxy (`ssrf_proxy`) that blocks private addresses. This guide doesn't run it. See [Security](#security) for what that means.
* **Real-time collaboration on workflows** (`api_websocket`), which needs a WebSocket service. `ENABLE_COLLABORATION_MODE=false` turns it off; each editor saves the workflow as usual.
* **Dify Agent's shell workspaces** (`agent_backend`, `local_sandbox`): the images keep this feature off by default.
* **Plugin debugging over port 5003.** `PLUGIN_REMOTE_INSTALLING_ENABLED=false` turns it off; without it, the plugin daemon doesn't start: see [Troubleshoot](#troubleshoot).

### The code sandbox without privileged containers

Sevalla doesn't run privileged containers, and the sandbox doesn't need one. `dify-sandbox` runs each piece of code as an unprivileged user in a `chroot` with a seccomp filter that allows only the system calls the language needs. In the tests on Sevalla:

| What the code did | Result |
| - | - |
| Python 3 and Node.js 20 code, the languages of Dify's Code node | Ran and returned its output |
| `os.system("id")` or `subprocess.run(["id"])` | `error: operation not permitted` |
| `os.getuid()` | `error: operation not permitted` |
| Read `/etc/passwd` | `FileNotFoundError`: the code sees only the sandbox's own folder |
| Request `https://example.com` | `200`: code can reach the internet |
| Connect to a database without an internal connection to `dify-sandbox` | Timed out |

The workflow at the end of this guide runs Python in a Code node this way.

## Before you begin

* A Sevalla company with a payment method: Dify needs the **S1** size for five processes.
* A GitHub, GitLab or Bitbucket account for the gateway's repository. See [Git providers](https://docs.sevalla.com/git/overview.md).
* OpenSSL on your computer, to generate keys (`openssl version` prints its version).
* An [Anthropic API key](https://platform.claude.com/settings/keys) for the workflow at the end. Set a monthly spending limit on the account.

## Create the databases and the bucket

**1. Create PostgreSQL with pgvector**

In the sidebar, click **Databases**, then click **Create database**. Choose **PostgreSQL** and the newest version (tested: 18), and select **Enable pgvector extension**. Enter `dify-db` as the **Display name**, choose a **Data center** and the **Size** **DB1**, then click **Create database**. You can turn the extension on only when you create a database.

**2. Create Valkey**

Click **Create database** again. Choose **Valkey** and the newest version (tested: 9.0), enter `dify-redis` as the **Display name**, choose the same **Data center**, and click **Create database**. Wait until both databases are **Ready**.

**3. Create the bucket**

In the sidebar, click **Object storage**, then click **Create object storage**. Enter `dify-files` as the **Name**, pick a **Location** and click **Create object storage**. On its **Settings** tab, note the **Endpoint**, the **Bucket name**, the **Access key** and the **Secret key**.

`dify-api` and `dify-plugin-daemon` share the database: the plugin daemon's tables (`plugins`, `plugin_installations` and others) have names that Dify doesn't use. Dify's Compose file gives the plugin daemon a database of its own, `dify_plugin`, which the daemon creates at startup, but the user of a Sevalla database can't create databases.

## Create the applications

Create four applications from Dify's images. For each one:

**1. Choose the image**

In the sidebar, click **Applications**, then click **Create** > **Application**. Under **Source**, click **Docker image**, enter the image from the table in **Docker image**, and keep the **Registry credential** at **None (public image)**.

**2. Name, place and size it**

Enter the **Name** from the table, choose the databases' **Data center**, and choose the **Size** from the table. Click **Create application**. It opens with the status **Not deployed yet**.

**3. Set the port and turn HTTP traffic off**

Click the **Networking** tab. In **Expose HTTP traffic**, click **Update port**, enter the port from the table and click **Update port**. Then click **Disable**, and **Disable HTTP** in the dialog. Note the web process's internal hostname that the tab shows, for example `dify-api-x7k2m-web.dify-api-x7k2m.svc.cluster.local`.

| Name | Docker image | Size | Port |
| - | - | - | - |
| `dify-api` | `langgenius/dify-api:1.18.0` | **S1** | `5001` |
| `dify-web` | `langgenius/dify-web:1.18.0` | **H1** | `3000` |
| `dify-sandbox` | `langgenius/dify-sandbox:0.2.15` | **H1** | `8194` |
| `dify-plugin-daemon` | `langgenius/dify-plugin-daemon:0.6.10-local` | **S1** | `5002` |

The tags are the ones Dify 1.18.0's Compose file uses; keep them together when you [upgrade](#upgrade-dify). The `-local` variant of the plugin daemon runs plugins as processes in its own container. `dify-api` needs **S1**: it uses about 620 MB.

## Create the gateway

The gateway is nginx with a configuration that routes each path to the right application. Create a repository, for example `acme/dify-gateway`, with two files:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FROM nginxinc/nginx-unprivileged:1.30-alpine
COPY default.conf.template /etc/nginx/templates/default.conf.template
```

```nginx default.conf.template theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
server {
    listen 8080;
    client_max_body_size 100m;

    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header Connection "";

    location = /healthz {
        access_log off;
        return 200 "ok\n";
    }

    location ~ ^/(console/api|api|v1|files|openapi|mcp|triggers)(/|$) {
        proxy_pass http://${DIFY_API_HOST}:5001;
    }

    location /e/ {
        proxy_pass http://${DIFY_PLUGIN_DAEMON_HOST}:5002;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Connection "";
        proxy_set_header Dify-Hook-Url https://$host$request_uri;
    }

    location / {
        proxy_pass http://${DIFY_WEB_HOST}:3000;
    }
}
```

| Line or choice | Why |
| - | - |
| `nginxinc/nginx-unprivileged:1.30-alpine` | NGINX's official image of the stable line, which runs as the unprivileged user `101` and listens on `8080`, Sevalla's default port. The line tag picks up patch releases when you deploy again. |
| `/etc/nginx/templates/` | At startup, the image replaces `${DIFY_API_HOST}` and the other environment variables in the template and writes the result to `/etc/nginx/conf.d/default.conf`. nginx's own variables, such as `$host`, stay as they are. |
| The `location` blocks | The paths of Dify's own nginx configuration: the API's paths, the plugin daemon's `/e/` endpoints, and the web interface for the rest. |
| `proxy_buffering off` and the 3600 s timeouts | Dify streams answers as server-sent events. nginx passes each event on as it arrives and keeps long requests open. |
| `X-Forwarded-For $http_cf_connecting_ip` | Cloudflare puts the visitor's address in `CF-Connecting-IP`. With `RESPECT_XFORWARD_HEADERS_ENABLED=true`, Dify records it at sign-in; without both, Dify records the gateway's private address. |
| `X-Forwarded-Proto https` | Visitors reach Sevalla over HTTPS, while the gateway receives plain HTTP from Sevalla's proxy. |
| `/healthz` | Answers the readiness probe without depending on the other applications. |

Push both files, then create the application:

**1. Create it from the repository**

In the sidebar, click **Applications**, then click **Create** > **Application**. Under **Source**, click **Private repository**, choose the **Git provider**, the **Repository** and the **Branch**. Enter the **Name** `dify`, choose the same **Data center**, keep the **Size** at **H1**, and click **Create application**. Note its domain, for example `dify-x7k2m.sevalla.app`: every URL setting of Dify needs it.

**2. Build from the Dockerfile**

On the **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**.

## Connect the services

Internal connections open the private network between two services. On each application's **Networking** tab, under **Connected services**, click **Add internal connection**, choose the **Service**, and click **Add internal connection**:

| Application | Services to connect | Variables to add |
| - | - | - |
| `dify-api` | `dify-db`, `dify-redis`, `dify-sandbox`, `dify-plugin-daemon` | Turn on **Add environment variables to the application** for the two databases, and keep the names. Off for the applications |
| `dify-plugin-daemon` | `dify-db`, `dify-redis` | On for both, keep the names |
| `dify-web` | `dify-api` | Off |
| `dify` | `dify-api`, `dify-web`, `dify-plugin-daemon` | Off |

A connection between two applications works in both directions, so `dify-plugin-daemon` reaches `dify-api` through the connection you added on `dify-api`; adding it again on the plugin daemon's side fails with `Internal connection between "<id>" and "<id>" already exists`. The connections from the databases add `DB_URL` and `REDIS_URL` too, which Dify doesn't read.

## Set the environment variables

Generate the keys on your computer and keep a copy in your password manager:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
openssl rand -base64 42   # SECRET_KEY
openssl rand -hex 32      # the sandbox key, the plugin daemon key, the inner API key: run it three times
openssl rand -hex 12      # INIT_PASSWORD
```

On each application's **Environment variables** tab, click **Add environment variables**, paste the lines into the first key field, replace the example values with yours, and click **Add environment variables**. The `${...}` values are [references](https://docs.sevalla.com/applications/environment-variables.md#reference-another-variable) to the variables the database connections added.

```bash dify-api theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CELERY_BROKER_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/1
SECRET_KEY=<SECRET_KEY>
INIT_PASSWORD=<INIT_PASSWORD>
CONSOLE_WEB_URL=https://dify-x7k2m.sevalla.app
CONSOLE_API_URL=https://dify-x7k2m.sevalla.app
SERVICE_API_URL=https://dify-x7k2m.sevalla.app
APP_WEB_URL=https://dify-x7k2m.sevalla.app
FILES_URL=https://dify-x7k2m.sevalla.app
TRIGGER_URL=https://dify-x7k2m.sevalla.app
INTERNAL_FILES_URL=http://dify-api-x7k2m-web.dify-api-x7k2m.svc.cluster.local:5001
STORAGE_TYPE=s3
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
S3_REGION=auto
S3_BUCKET_NAME=dify-files-x7k2m
S3_ACCESS_KEY=<access key>
S3_SECRET_KEY=<secret key>
VECTOR_STORE=pgvector
PGVECTOR_HOST=${DB_HOST}
PGVECTOR_PORT=${DB_PORT}
PGVECTOR_USER=${DB_USERNAME}
PGVECTOR_PASSWORD=${DB_PASSWORD}
PGVECTOR_DATABASE=${DB_DATABASE}
CODE_EXECUTION_ENDPOINT=http://dify-sandbox-x7k2m-web.dify-sandbox-x7k2m.svc.cluster.local:8194
CODE_EXECUTION_API_KEY=<sandbox key>
PLUGIN_DAEMON_URL=http://dify-plugin-daemon-x7k2m-web.dify-plugin-daemon-x7k2m.svc.cluster.local:5002
PLUGIN_DAEMON_KEY=<plugin daemon key>
INNER_API_KEY_FOR_PLUGIN=<inner API key>
ENABLE_COLLABORATION_MODE=false
RESPECT_XFORWARD_HEADERS_ENABLED=true
```

```bash dify-plugin-daemon theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SERVER_KEY=<plugin daemon key>
DIFY_INNER_API_URL=http://dify-api-x7k2m-web.dify-api-x7k2m.svc.cluster.local:5001
DIFY_INNER_API_KEY=<inner API key>
PLUGIN_WORKING_PATH=/app/storage/cwd
PLUGIN_REMOTE_INSTALLING_ENABLED=false
PLUGIN_STORAGE_TYPE=aws_s3
PLUGIN_STORAGE_OSS_BUCKET=dify-files-x7k2m
S3_USE_AWS=false
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
AWS_REGION=auto
AWS_ACCESS_KEY=<access key>
AWS_SECRET_KEY=<secret key>
```

```bash dify-web theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CONSOLE_API_URL=https://dify-x7k2m.sevalla.app
APP_API_URL=https://dify-x7k2m.sevalla.app
SERVER_CONSOLE_API_URL=http://dify-api-x7k2m-web.dify-api-x7k2m.svc.cluster.local:5001
```

```bash dify-sandbox theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
API_KEY=<sandbox key>
GIN_MODE=release
WORKER_TIMEOUT=15
```

```bash dify theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DIFY_API_HOST=dify-api-x7k2m-web.dify-api-x7k2m.svc.cluster.local
DIFY_WEB_HOST=dify-web-x7k2m-web.dify-web-x7k2m.svc.cluster.local
DIFY_PLUGIN_DAEMON_HOST=dify-plugin-daemon-x7k2m-web.dify-plugin-daemon-x7k2m.svc.cluster.local
```

| Setting | Why Dify needs it on Sevalla |
| - | - |
| `STORAGE_TYPE=s3` and the `S3_*` variables | Dify keeps uploaded files and each workspace's private key (`privkeys/<workspace>/private.pem`) in storage. With the default, a folder in the container, every deployment deletes them, and Dify can no longer decrypt the model provider keys stored in the database. |
| `PLUGIN_STORAGE_TYPE=aws_s3` | The plugin daemon keeps plugin packages in the same bucket, so installed plugins survive deployments. At startup, it installs each plugin's Python packages again into `PLUGIN_WORKING_PATH`. |
| `VECTOR_STORE=pgvector` | Knowledge bases store their vectors in `dify-db`, one table per knowledge base, instead of a separate vector database. |
| `CONSOLE_API_URL` and `CONSOLE_WEB_URL` starting with `https://` | Dify marks its sign-in cookies `Secure` and names them with the `__Host-` prefix only when both URLs use HTTPS. |
| `SERVER_CONSOLE_API_URL` | `dify-web` renders some pages on the server, and calls `dify-api` over the private network for them. |

`dify-api` reads the database and Valkey settings under the names the internal connections add, `DB_*` and `REDIS_*`, and so does the plugin daemon.

## Add the processes

`dify-api`'s image starts the API by default. Its other roles run from the same image with another start command. On `dify-api`'s **Processes** tab:

**1. Create the migration job**

Click **Create process**, choose **Job**, enter `Migrate` as the **Name** and `exec flask upgrade-db` as the **Custom start command**. Keep the **Start policy** at **Before deployment**, choose the **Resource** **S1**, and click **Create process**.

**2. Create the worker**

Click **Create process**, choose **Background worker**, enter `Worker` as the **Name** and `exec env MODE=worker /bin/bash /entrypoint.sh` as the **Custom start command**, choose **S1**, and click **Create process**.

**3. Create the scheduler**

Create another **Background worker** named `Beat` with the command `exec env MODE=beat /bin/bash /entrypoint.sh` on **S1**.

* **`Migrate`** runs Dify's database migrations before each deployment, so a failed migration leaves the running version in place. On an empty database, it took 51 to 88 s in the tests.
* **`Worker`** runs Celery: it indexes knowledge base documents, runs scheduled and queued workflows, and sends mail. The image's entrypoint starts it with the gevent pool and a concurrency of 1 (`CELERY_WORKER_AMOUNT`).
* **`Beat`** queues Dify's periodic tasks: schedule triggers every minute, cleanups and plugin update checks. Keep it at 1 instance, or every task runs twice. On **H1**, it stopped with exit code `137` (out of memory) as it started.
* `env MODE=...` sets the variable that the image's `/entrypoint.sh` reads, and `exec` hands the process the `SIGTERM` that Sevalla sends at shutdown.

## Add the readiness probes

A readiness probe keeps the previous instance serving until the new one answers. On each application's **Processes** tab, click **⋯** on the web process, then **Update process**. Under **Health checks**, turn on **Readiness probe**, enter the **Path** and keep the **Port** at the web process port. Click **Update process**.

| Application | Path |
| - | - |
| `dify-api` | `/health` |
| `dify-web` | `/favicon.ico` |
| `dify-sandbox` | `/health` |
| `dify-plugin-daemon` | `/health/check` |
| `dify` | `/healthz` |

## Deploy Dify

Deploy `dify-sandbox`, `dify-plugin-daemon`, `dify-api` and `dify-web` first: on each one's **Overview** tab, click **Deploy now**, then **Deploy**. They can deploy at the same time; in the test, all four were **Deployed** in under 2 minutes. Then deploy `dify`. The gateway resolves the other applications' hostnames when it starts, so deploy it after them.

> [!WARNING]
>
> The first visitor to `https://dify-x7k2m.sevalla.app/install` can create Dify's admin account. `INIT_PASSWORD` protects that page: keep it secret until you've created the account.

**1. Open the setup page**

Open `https://dify-x7k2m.sevalla.app/install`, with the gateway's domain. Dify asks for the **Admin initialization password** first: enter `INIT_PASSWORD`.

**2. Create the admin account**

Enter your email address, a name and a password of at least 8 characters with letters and numbers, and continue. Dify signs you in.

## Verify the deployment

1. On each application's **Deployments** tab, the deployment is **Deployed**. With the API, [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns `"status": "success"`.

2. The `dify-api` build log shows `Migrate has been successful`, then `Web has been deployed successfully`, `Worker has been deployed successfully` and `Beat has been deployed successfully`.

3. Before you create the admin account, Dify's setup status answers through the gateway:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://dify-x7k2m.sevalla.app/console/api/setup
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"step":"not_started","setup_at":null}
   ```

   After you create it, `step` is `finished`.

4. The web interface answers:

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

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   <title>Sign in - Dify</title>
   ```

5. The gateway's own health check:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://dify-x7k2m.sevalla.app/healthz
   ```

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

6. The migration job's result, from [Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`), with `API`, `H` and `API_APP_ID` set as in [Deploy with the API](#deploy-with-the-api):

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$API_APP_ID/runtime-logs?search=Database%20migration%20successful" -H "$H" | jq -r '.[0].message'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Database migration successful!
   ```

In the runtime logs, the worker logs `celery@<instance> ready.`, the scheduler `beat: Starting...`, and the plugin daemon `dify plugin db initialized`.

## Run a workflow that calls Claude

Dify connects to model providers through plugins, which the plugin daemon downloads from the [Dify Marketplace](https://marketplace.dify.ai) and runs.

**1. Install the Anthropic plugin**

In Dify, open **Plugins**, search the **Marketplace** for **Anthropic**, and install it. The plugin daemon stores the package in the bucket and starts it.

**2. Add your API key**

Open **Settings** > **Model Provider**, choose **Anthropic**, and add your Anthropic API key. Dify checks the key with Anthropic, then encrypts it with the workspace's key and stores it in `dify-db`.

**3. Import the workflow**

In **Studio**, choose **Import DSL file** and import the YAML below, saved as `order-summary.yml`. Dify creates the **Order summary** workflow.

**4. Publish it and create an API key**

Open the workflow, click **Publish**, then open **API Access** and click **Create new Secret key**. Dify shows the base URL `https://dify-x7k2m.sevalla.app/v1`.

```yaml order-summary.yml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
app:
  mode: workflow
  name: Order summary
kind: app
version: 0.7.0
dependencies:
- type: marketplace
  value:
    marketplace_plugin_unique_identifier: langgenius/anthropic:0.3.39@a9b013a5d1617da8db039acc0e0e1fb491832cd050aaa7d6acadf4fc06fe4c58
workflow:
  conversation_variables: []
  environment_variables: []
  features: {}
  graph:
    edges:
    - id: start-total
      source: start
      target: total
      sourceHandle: source
      targetHandle: target
      type: custom
      data: {sourceType: start, targetType: code}
    - id: total-reply
      source: total
      target: reply
      sourceHandle: source
      targetHandle: target
      type: custom
      data: {sourceType: code, targetType: llm}
    - id: reply-end
      source: reply
      target: end
      sourceHandle: source
      targetHandle: target
      type: custom
      data: {sourceType: llm, targetType: end}
    nodes:
    - id: start
      type: custom
      position: {x: 0, y: 0}
      data:
        type: start
        title: Start
        variables:
        - variable: items
          label: Items
          type: paragraph
          required: true
          max_length: 2000
    - id: total
      type: custom
      position: {x: 300, y: 0}
      data:
        type: code
        title: Total
        code_language: python3
        code: |
          import json


          def main(items: str) -> dict:
              order = json.loads(items)
              total = sum(item["qty"] * item["price"] for item in order)
              return {"total": round(total, 2)}
        variables:
        - variable: items
          value_selector: [start, items]
        outputs:
          total:
            type: number
            children: null
    - id: reply
      type: custom
      position: {x: 600, y: 0}
      data:
        type: llm
        title: Reply
        model:
          provider: langgenius/anthropic/anthropic
          name: claude-haiku-5-5
          mode: chat
          completion_params:
            max_tokens: 400
            effort: low
        prompt_template:
        - role: system
          text: You confirm orders for a small shop. Reply to the customer in one sentence that names the items and the total in dollars.
        - role: user
          text: 'Items: {{#start.items#}}. Total: {{#total.total#}}'
        context:
          enabled: false
          variable_selector: []
        vision:
          enabled: false
    - id: end
      type: custom
      position: {x: 900, y: 0}
      data:
        type: end
        title: End
        outputs:
        - variable: total
          value_selector: [total, total]
          value_type: number
        - variable: reply
          value_selector: [reply, text]
          value_type: string
```

The **Total** node runs Python in `dify-sandbox` to add up the order, and the **Reply** node sends the items and the total to Claude Haiku 5.5, Anthropic's fastest and cheapest current model; change `name` under `model` for another one. The `dependencies` entry names the plugin the workflow needs. Run the workflow with your API secret key in `APP_KEY`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST https://dify-x7k2m.sevalla.app/v1/workflows/run \
  -H "Authorization: Bearer $APP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"inputs": {"items": "[{\"name\": \"Coffee mug\", \"qty\": 3, \"price\": 12.5}, {\"name\": \"Tea\", \"qty\": 2, \"price\": 4.99}]"}, "response_mode": "blocking", "user": "docs-check"}' \
  | jq -c '.data | {status, total: .outputs.total}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"status":"succeeded","total":47.48}
```

`outputs.reply` holds Claude's sentence, for example `Thanks for your order! You've purchased 3 Coffee Mugs and 2 Tea packs, for a total of $47.48.`; the wording changes from run to run. A run used about 150 tokens and took 1.3 s.

With `"response_mode": "streaming"`, Dify sends server-sent events as each node runs and as Claude writes. They reach the client through the gateway, Sevalla's proxy and Cloudflare as they're sent:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -sN -X POST https://dify-x7k2m.sevalla.app/v1/workflows/run \
  -H "Authorization: Bearer $APP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"inputs": {"items": "[{\"name\": \"Coffee mug\", \"qty\": 3, \"price\": 12.5}]"}, "response_mode": "streaming", "user": "docs-check"}' \
  | grep -oE '"event":"workflow_(started|finished)"'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
"event":"workflow_started"
"event":"workflow_finished"
```

In the tests, the first event arrived 0.4 to 0.5 s after the request and the last one after 1.7 to 1.9 s, with `text_chunk` events from Claude in between. The first run after a deployment took 7.5 s to start.

## Add a knowledge base

A knowledge base stores your documents as chunks with embeddings, and Dify's Knowledge Retrieval node and chat apps search it. With `VECTOR_STORE=pgvector`, the vectors go to `dify-db`, one table per knowledge base, for example `embedding_vector_index_727a920c_a24b_4fa6_bc87_d70acd386a31_nod`.

Anthropic has no embeddings model, so install a provider that has one, such as the **OpenAI** plugin with your OpenAI API key, and choose its `text-embedding-3-small` as the embedding model when you create the knowledge base. Uploaded files go to the bucket under `upload_files/`, and the `Worker` process splits, embeds and stores them. In the test, a one-page returns policy was indexed in about 1 s, and the query `My mug arrived cracked, what do I do?` returned it with a score of `0.464`.

To add documents from a script, use Dify's Knowledge API with a knowledge base API key. In Dify 1.18.0, `POST /v1/datasets/<id>/document/create-by-file` fails with `500`; `create-by-text`, and uploads in the web interface, work.

## Security

* **The admin account.** Set `INIT_PASSWORD`, and create the admin account right after the first deployment. Without the initialization password, Dify's setup API answers `401` with `{"error":{"type":"authentication_error","message":"Unauthorized","status":401}}`.
* **Code and HTTP requests in workflows.** Without the SSRF proxy, a Code node or an HTTP Request node can reach the internet and, on Sevalla's private network, other services. In the tests, code in the sandbox couldn't reach a database it had no internal connection to, but it could open connections to other applications in the same company, including ones it had no internal connection to. Let only people you trust edit workflows, and require authentication on every private service, as Dify's own services do with their keys.
* **Public endpoints that spend money.** Every published app's API and web app call your model providers. Keep app API keys on your servers, not in browsers, and set spending limits with each provider.
* **Plugins** run as processes inside `dify-plugin-daemon` with its environment, which includes the database password and the bucket keys. `FORCE_VERIFYING_SIGNATURE` is on by default, so the daemon refuses plugins without a valid signature. Install only plugins you trust.

### Keep the keys

* `SECRET_KEY` signs sign-in sessions and encrypts the OAuth settings of tools and triggers. Changing it signs everyone out, and Dify can no longer read those settings.
* The bucket holds each workspace's private key, which decrypts the model provider keys in `dify-db`. Back up `dify-db` and don't empty or delete the bucket: without it, every provider must be set up again.
* `PLUGIN_DAEMON_KEY`, `INNER_API_KEY_FOR_PLUGIN` and the sandbox key are shared secrets between two applications: change both sides together.

## Measured resources

Measured on Oct 30, 2026, with the Anthropic and OpenAI plugins installed:

| Process | Size | Memory used |
| - | - | - |
| `dify-api` web process | **S1** | 621 MB |
| `dify-api` `Worker` | **S1** | 601 MB |
| `dify-api` `Beat` | **S1** | 566 MB |
| `dify-plugin-daemon` | **S1** | 412 MB, with two plugins running |
| `dify-web` | **H1** | 119 MB |
| `dify-sandbox` | **H1** | 262 MB, of which 6 MB for its processes and the rest file cache that it can release |
| `dify` (gateway) | **H1** | 15 MB |

Each plugin is a Python process in `dify-plugin-daemon`: move it to a larger size as you install more. Installing the Anthropic and OpenAI plugins took 19 s. The API's web process runs Gunicorn with 1 gevent worker and up to 10 connections at a time (`SERVER_WORKER_CONNECTIONS`), and each streaming answer holds one; raise the variable or the instance count for more parallel users.

## Upgrade Dify

Dify migrates its database when a new version's `Migrate` job runs, and the migration can't be undone by deploying the old image.

1. Read the release notes of every version between yours and the new one on [Dify's GitHub releases](https://github.com/langgenius/dify/releases), and the image tags in that release's `docker/docker-compose.yaml`.
2. Back up `dify-db`: on its **Backups** tab, create a manual backup. See [Backups](https://docs.sevalla.com/databases/backups.md).
3. On `dify-api`'s **Settings** tab, in the **Source** section, click **Update source**, change the tag in **Docker image**, click **Update source**, and deploy. The `Migrate` job runs the migrations, and the worker and scheduler move to the new image with the same deployment.
4. Change `dify-web`'s tag to the same version and deploy, and change `dify-sandbox` and `dify-plugin-daemon` when the release's Compose file uses new tags for them.

To go back, restore the backup and set the previous tags.

## Add a custom domain

To serve Dify from your own domain, such as `dify.example.com`, add it on the `dify` application's **Domains** tab and create the DNS records Sevalla shows; the gateway must be larger than **H1** first, so [scale](https://docs.sevalla.com/applications/scaling.md) it. Then change every URL variable on `dify-api` (`CONSOLE_WEB_URL`, `CONSOLE_API_URL`, `SERVICE_API_URL`, `APP_WEB_URL`, `FILES_URL`, `TRIGGER_URL`) and `dify-web` (`CONSOLE_API_URL`, `APP_API_URL`) to `https://dify.example.com`, and deploy both. Sign-in cookies belong to a domain, so everyone signs in again. See [Custom domains](https://docs.sevalla.com/domains/overview.md).

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| `dify-plugin-daemon` restarts, and its log shows `invalid configuration: plugin remote installing host is empty` | Plugin debugging is on by default and needs a host and port | Set `PLUGIN_REMOTE_INSTALLING_ENABLED=false` and deploy |
| `dify-plugin-daemon` restarts, and its log shows `failed to init dify plugin db error="ERROR: permission denied to create database (SQLSTATE 42501)"` | `DB_DATABASE` names a database that doesn't exist, such as Dify's default `dify_plugin`, and the database user can't create one | Set `DB_DATABASE` to the database's own name, `dify`, and deploy |
| `Beat` keeps restarting, and the process's exit code is `137` | It runs out of memory on **H1** while it starts | Use **S1** |
| A Code node fails with `network is disabled, please enable it in the configuration` | `ENABLE_NETWORK=false` on `dify-sandbox`. Dify requests network access for every run | Remove `ENABLE_NETWORK` and deploy `dify-sandbox` |
| Signing in through the console API returns `{"code":"authentication_failed","message":"Invalid encrypted data","status":401}` | `/console/api/login` expects the password Base64-encoded, as the web interface sends it | Send `printf '%s' "$PASSWORD" \| base64` as `password` |
| Dify's setup API returns `401` with `{"error":{"type":"authentication_error","message":"Unauthorized","status":401}}` | `INIT_PASSWORD` is set and the session hasn't sent it | Call `POST /console/api/init` with the password first, with the same cookie jar |
| Adding an internal connection returns `409` with `Internal connection between "<id>" and "<id>" already exists` | The two applications are already connected from the other side | Nothing to do: the connection works both ways |
| Dify records the gateway's private address, such as `10.244.12.182`, as each user's last sign-in address | `RESPECT_XFORWARD_HEADERS_ENABLED` isn't `true`, or the gateway doesn't send `X-Forwarded-For` | Set the variable, use the gateway configuration in [Create the gateway](#create-the-gateway), and deploy both |
| `POST /v1/datasets/<id>/document/create-by-file` returns `500` with `{"message":"Internal Server Error","code":"unknown","status":500}`, and `dify-api`'s log shows `AssertionError` in `save_document_with_dataset_id` | A Dify 1.18.0 issue in the Knowledge API | Use `create-by-text`, or upload the file in the web interface |

## Deploy with the API

These calls create the same deployment without the dashboard. They need:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, or the capabilities to create applications, databases and object storage, in `SEVALLA_API_KEY`.
* The gateway's repository pushed to GitHub, and a GitHub account with access to it connected to Sevalla. If no connected account can read the repository, the step that creates the gateway fails with `400` and a message that starts with `Cannot access repository`.
* `curl`, `jq` and `openssl`.

The sequence creates two databases, a bucket and five applications. The API allows 20 creations per hour of each kind per company.

```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/dify-gateway
BRANCH=main
```

**1. Choose the location and the sizes**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`), [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`). Pick the location by its region ID, here Frankfurt, Germany:

```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')
H1_ID=$(curl -s "$API/resources/process-resource-types" -H "$H" | jq -r '.[] | select(.name == "h1") | .id')
S1_ID=$(curl -s "$API/resources/process-resource-types" -H "$H" | jq -r '.[] | select(.name == "s1") | .id')
DB1_ID=$(curl -s "$API/resources/database-resource-types" -H "$H" | jq -r '.[] | select(.name == "db1") | .id')
```

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

**2. Create the databases**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) twice, then [Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) until both are `ready`. You choose the passwords, and the API never returns them:

```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 c "$CLUSTER_ID" --arg r "$DB1_ID" --arg p "$DB_PASSWORD" \
    '{display_name: "dify-db", type: "postgresql", version: "18", cluster_id: $c, resource_type_id: $r,
      db_name: "dify", db_user: "dify", db_password: $p, extensions: {enable_vector: true}}')" | jq -r .id)
REDIS_PASSWORD=$(openssl rand -hex 16)
REDIS_ID=$(curl -s -X POST "$API/databases" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg c "$CLUSTER_ID" --arg r "$DB1_ID" --arg p "$REDIS_PASSWORD" \
    '{display_name: "dify-redis", type: "valkey", version: "9.0", cluster_id: $c, resource_type_id: $r,
      db_name: "dify-redis", db_password: $p}')" | jq -r .id)
for id in $DB_ID $REDIS_ID; do
  for i in $(seq 1 60); do
    [ "$(curl -s "$API/databases/$id" -H "$H" | jq -r '.status // empty')" = ready ] && break
    sleep 10
  done
  curl -s "$API/databases/$id" -H "$H" | jq -r '.status'
done
DB_HOST=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r .internal_hostname)
REDIS_HOST=$(curl -s "$API/databases/$REDIS_ID" -H "$H" | jq -r .internal_hostname)
```

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

CLI: `sevalla databases create`, but it has no option for the pgvector extension, so create `dify-db` with the API. MCP: `sevalla_api_create` with `createDatabaseV3` and the same bodies (`create_database` generates a password it doesn't return, and Dify needs it).

**3. Create the bucket**

[Create object storage bucket](https://api-docs.sevalla.com/v3/object-storages/create-object-storage-bucket) (`createObjectStorageV3`), then [Get object storage bucket](https://api-docs.sevalla.com/v3/object-storages/get-object-storage-bucket) (`getObjectStorageV3`): the create response doesn't include the keys.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
BUCKET_ID=$(curl -s -X POST "$API/object-storage" -H "$H" -H "Content-Type: application/json" \
  -d '{"display_name": "dify-files", "location": "weur"}' | jq -r .id)
BUCKET=$(curl -s "$API/object-storage/$BUCKET_ID" -H "$H")
```

`BUCKET` holds the endpoint, the bucket name and the keys; don't print it. CLI: `sevalla object-storage create --name dify-files --location weur` and `sevalla object-storage get $BUCKET_ID`. MCP: `sevalla_api_create` with `createObjectStorageV3`, then `sevalla_api_read` with `getObjectStorageV3`.

**4. Create the applications**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) five times. None of them deploys yet:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
create_app() {
  curl -s -X POST "$API/applications" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --arg c "$CLUSTER_ID" --arg name "$1" --arg image "$2" \
      '{display_name: $name, cluster_id: $c, source: "dockerImage", docker_image: $image}')" | jq -r .id
}
SANDBOX_APP_ID=$(create_app dify-sandbox langgenius/dify-sandbox:0.2.15)
API_APP_ID=$(create_app dify-api langgenius/dify-api:1.18.0)
PLUGIN_APP_ID=$(create_app dify-plugin-daemon langgenius/dify-plugin-daemon:0.6.10-local)
WEB_APP_ID=$(create_app dify-web langgenius/dify-web:1.18.0)
GATEWAY_APP_ID=$(curl -s -X POST "$API/applications" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg c "$CLUSTER_ID" --arg repo "$REPO_URL" --arg branch "$BRANCH" \
    '{display_name: "dify", cluster_id: $c, source: "privateGit", git_type: "github", repo_url: $repo, default_branch: $branch}')" \
  | jq -r .id)
```

CLI: `sevalla apps create --name dify-api --cluster $CLUSTER_ID --source dockerImage --docker-image langgenius/dify-api:1.18.0`, and `--source privateGit --git-type github --repository $REPO_URL --branch $BRANCH` for the gateway. MCP: `create_application` with `deploy: false`.

**5. Read the hostnames and the domain**

[List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) returns each web process's ID and internal hostname, and [List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) the gateway's domain, about a second after it's created:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
web() {
  curl -s "$API/applications/$1/processes" -H "$H" | jq -r --arg field "$2" '.data[] | select(.type == "web") | .[$field]'
}
SANDBOX_HOST=$(web $SANDBOX_APP_ID internal_hostname)
API_HOST=$(web $API_APP_ID internal_hostname)
PLUGIN_HOST=$(web $PLUGIN_APP_ID internal_hostname)
WEB_HOST=$(web $WEB_APP_ID internal_hostname)
for i in $(seq 1 30); do
  DIFY_HOST=$(curl -s "$API/applications/$GATEWAY_APP_ID/domains" -H "$H" | jq -r '.data[] | select(.is_primary) | .name')
  [ -n "$DIFY_HOST" ] && break
  sleep 2
done
DIFY_URL=https://$DIFY_HOST
```

CLI: `sevalla apps processes list --app-id $API_APP_ID` and `sevalla apps domains list --app-id $GATEWAY_APP_ID`. MCP: `get_application`.

**6. Configure the web processes**

[Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets each port, turns HTTP traffic off (`is_ingress_enabled: false`), and sets the size and the readiness probe. Send `timeoutSeconds`: without it, a probe set through the API waits 120 seconds instead of the dashboard's 1.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
set_web() {
  curl -s -X PATCH "$API/applications/$1/processes/$(web $1 id)" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --argjson port "$2" --arg size "$3" --arg path "$4" \
      '{port: $port, is_ingress_enabled: false, resource_type_id: $size,
        readiness_probe: {httpGet: {path: $path, port: $port}, initialDelaySeconds: 5, periodSeconds: 5,
          timeoutSeconds: 1, successThreshold: 1, failureThreshold: 3}}')" \
    | jq -c '{port, is_ingress_enabled, probe: .readiness_probe.httpGet.path}'
}
set_web $SANDBOX_APP_ID 8194 $H1_ID /health
set_web $API_APP_ID 5001 $S1_ID /health
set_web $PLUGIN_APP_ID 5002 $S1_ID /health/check
set_web $WEB_APP_ID 3000 $H1_ID /favicon.ico
curl -s -X PATCH "$API/applications/$GATEWAY_APP_ID/processes/$(web $GATEWAY_APP_ID id)" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg size "$H1_ID" '{resource_type_id: $size, readiness_probe: {httpGet: {path: "/healthz", port: 8080},
    initialDelaySeconds: 5, periodSeconds: 5, timeoutSeconds: 1, successThreshold: 1, failureThreshold: 3}}')" \
  | jq -c '{port, is_ingress_enabled}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"port":8194,"is_ingress_enabled":false,"probe":"/health"}
{"port":5001,"is_ingress_enabled":false,"probe":"/health"}
{"port":5002,"is_ingress_enabled":false,"probe":"/health/check"}
{"port":3000,"is_ingress_enabled":false,"probe":"/favicon.ico"}
{"port":8080,"is_ingress_enabled":true}
```

CLI: `sevalla apps processes update <process-id> --app-id <app-id> --resource-type $S1_ID` sets the size; it has no option for the port, HTTP traffic or the probe. MCP: `sevalla_api_update` with `updateProcessV3` and the same bodies.

**7. Connect the services**

[Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`). Through the API, a connection adds no variables; the next step adds them:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
connect() {
  curl -s -X POST "$API/applications/$1/internal-connections" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --arg target "$2" --arg type "$3" '{target_id: $target, target_type: $type}')" | jq -r 'has("id")'
}
connect $API_APP_ID $DB_ID database
connect $API_APP_ID $REDIS_ID database
connect $API_APP_ID $SANDBOX_APP_ID app
connect $API_APP_ID $PLUGIN_APP_ID app
connect $PLUGIN_APP_ID $DB_ID database
connect $PLUGIN_APP_ID $REDIS_ID database
connect $WEB_APP_ID $API_APP_ID app
connect $GATEWAY_APP_ID $API_APP_ID app
connect $GATEWAY_APP_ID $WEB_APP_ID app
connect $GATEWAY_APP_ID $PLUGIN_APP_ID app
```

Each call prints `true`. CLI: `sevalla apps internal-connections create $API_APP_ID --target-id $DB_ID --target-type database`, once per line. MCP: `sevalla_api_create` with `createApplicationInternalConnectionV3`.

**8. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per variable, all for runtime only. Store `SECRET_KEY` and `INIT_PASSWORD` somewhere safe:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SANDBOX_API_KEY=$(openssl rand -hex 32)
PLUGIN_DAEMON_KEY=$(openssl rand -hex 32)
INNER_API_KEY=$(openssl rand -hex 32)
INIT_PASSWORD=$(openssl rand -hex 12)
add_var() {
  curl -s -X POST "$API/applications/$1/env-vars" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --arg key "$2" --arg value "$3" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}')" \
    | jq -r .key
}
add_vars() {
  app=$1; shift
  for pair in "$@"; do add_var "$app" "${pair%%=*}" "${pair#*=}"; done | paste -sd ' ' -
}
add_vars $SANDBOX_APP_ID "API_KEY=$SANDBOX_API_KEY" "GIN_MODE=release" "WORKER_TIMEOUT=15"
add_vars $API_APP_ID \
  "DB_HOST=$DB_HOST" "DB_PORT=5432" "DB_DATABASE=dify" "DB_USERNAME=dify" "DB_PASSWORD=$DB_PASSWORD" \
  "REDIS_HOST=$REDIS_HOST" "REDIS_PORT=6379" "REDIS_PASSWORD=$REDIS_PASSWORD" \
  "SECRET_KEY=$(openssl rand -base64 42)" "INIT_PASSWORD=$INIT_PASSWORD" \
  "CONSOLE_WEB_URL=$DIFY_URL" "CONSOLE_API_URL=$DIFY_URL" "SERVICE_API_URL=$DIFY_URL" \
  "APP_WEB_URL=$DIFY_URL" "FILES_URL=$DIFY_URL" "TRIGGER_URL=$DIFY_URL" \
  "INTERNAL_FILES_URL=http://$API_HOST:5001" \
  'CELERY_BROKER_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/1' \
  "STORAGE_TYPE=s3" "S3_ENDPOINT=$(echo "$BUCKET" | jq -r .endpoint)" "S3_REGION=auto" \
  "S3_BUCKET_NAME=$(echo "$BUCKET" | jq -r .bucket_name)" \
  "S3_ACCESS_KEY=$(echo "$BUCKET" | jq -r .access_key)" "S3_SECRET_KEY=$(echo "$BUCKET" | jq -r .secret_key)" \
  "VECTOR_STORE=pgvector" 'PGVECTOR_HOST=${DB_HOST}' 'PGVECTOR_PORT=${DB_PORT}' 'PGVECTOR_USER=${DB_USERNAME}' \
  'PGVECTOR_PASSWORD=${DB_PASSWORD}' 'PGVECTOR_DATABASE=${DB_DATABASE}' \
  "CODE_EXECUTION_ENDPOINT=http://$SANDBOX_HOST:8194" "CODE_EXECUTION_API_KEY=$SANDBOX_API_KEY" \
  "PLUGIN_DAEMON_URL=http://$PLUGIN_HOST:5002" "PLUGIN_DAEMON_KEY=$PLUGIN_DAEMON_KEY" \
  "INNER_API_KEY_FOR_PLUGIN=$INNER_API_KEY" \
  "ENABLE_COLLABORATION_MODE=false" "RESPECT_XFORWARD_HEADERS_ENABLED=true"
add_vars $PLUGIN_APP_ID \
  "DB_HOST=$DB_HOST" "DB_PORT=5432" "DB_DATABASE=dify" "DB_USERNAME=dify" "DB_PASSWORD=$DB_PASSWORD" \
  "REDIS_HOST=$REDIS_HOST" "REDIS_PORT=6379" "REDIS_PASSWORD=$REDIS_PASSWORD" \
  "SERVER_KEY=$PLUGIN_DAEMON_KEY" "DIFY_INNER_API_URL=http://$API_HOST:5001" "DIFY_INNER_API_KEY=$INNER_API_KEY" \
  "PLUGIN_WORKING_PATH=/app/storage/cwd" "PLUGIN_REMOTE_INSTALLING_ENABLED=false" \
  "PLUGIN_STORAGE_TYPE=aws_s3" "PLUGIN_STORAGE_OSS_BUCKET=$(echo "$BUCKET" | jq -r .bucket_name)" \
  "S3_USE_AWS=false" "S3_ENDPOINT=$(echo "$BUCKET" | jq -r .endpoint)" "AWS_REGION=auto" \
  "AWS_ACCESS_KEY=$(echo "$BUCKET" | jq -r .access_key)" "AWS_SECRET_KEY=$(echo "$BUCKET" | jq -r .secret_key)"
add_vars $WEB_APP_ID "CONSOLE_API_URL=$DIFY_URL" "APP_API_URL=$DIFY_URL" "SERVER_CONSOLE_API_URL=http://$API_HOST:5001"
add_vars $GATEWAY_APP_ID "DIFY_API_HOST=$API_HOST" "DIFY_WEB_HOST=$WEB_HOST" "DIFY_PLUGIN_DAEMON_HOST=$PLUGIN_HOST"
```

Each line of output lists the keys added to one application. The single quotes keep `${REDIS_PASSWORD}` and the other references for Sevalla to resolve at deployment. CLI: `sevalla apps env-vars create <app-id> --key <key> --value <value>`, once per variable; it makes each variable available during build and runtime, which makes no difference for an image. MCP: `set_environment_variables`.

**9. Create dify-api's other processes**

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
add_process() {
  curl -s -X POST "$API/applications/$API_APP_ID/processes" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --arg name "$1" --arg type "$2" --arg command "$3" --arg size "$S1_ID" \
      '{display_name: $name, type: $type, entrypoint: $command, resource_type_id: $size,
        scaling_strategy: {type: "manual", config: {instanceCount: 1}}}
       + (if $type == "job" then {job_start_policy: "beforeDeployment"} else {} end)')" \
    | jq -c '{display_name, entrypoint}'
}
add_process Migrate job "exec flask upgrade-db"
add_process Worker worker "exec env MODE=worker /bin/bash /entrypoint.sh"
add_process Beat worker "exec env MODE=beat /bin/bash /entrypoint.sh"
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"display_name":"Migrate","entrypoint":"exec flask upgrade-db"}
{"display_name":"Worker","entrypoint":"exec env MODE=worker /bin/bash /entrypoint.sh"}
{"display_name":"Beat","entrypoint":"exec env MODE=beat /bin/bash /entrypoint.sh"}
```

CLI: `sevalla apps processes create $API_APP_ID --name Worker --type worker --resource-type $S1_ID --command "exec env MODE=worker /bin/bash /entrypoint.sh"`, and `--type job --job-start-policy beforeDeployment` for `Migrate`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**10. Build the gateway from its Dockerfile**

[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/$GATEWAY_APP_ID" -H "$H" -H "Content-Type: application/json" \
  -d '{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}' | jq -r .build_type
```

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

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

**11. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) for the four image applications, then [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until each is final, then the same for the gateway:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
trigger() {
  curl -s -X POST "$API/applications/$1/deployments" -H "$H" -H "Content-Type: application/json" -d "$2" | jq -r .id
}
wait_for() {
  for i in $(seq 1 120); do
    STATUS=$(curl -s "$API/applications/$1/deployments/$2" -H "$H" | jq -r '.status // empty')
    case $STATUS in success|failed|cancelled|skipped) break ;; esac
    sleep 15
  done
  echo "$STATUS"
}
SANDBOX_DEPLOYMENT=$(trigger $SANDBOX_APP_ID '{}')
PLUGIN_DEPLOYMENT=$(trigger $PLUGIN_APP_ID '{}')
API_DEPLOYMENT=$(trigger $API_APP_ID '{}')
WEB_DEPLOYMENT=$(trigger $WEB_APP_ID '{}')
wait_for $SANDBOX_APP_ID $SANDBOX_DEPLOYMENT
wait_for $PLUGIN_APP_ID $PLUGIN_DEPLOYMENT
wait_for $API_APP_ID $API_DEPLOYMENT
wait_for $WEB_APP_ID $WEB_DEPLOYMENT
GATEWAY_DEPLOYMENT=$(trigger $GATEWAY_APP_ID "$(jq -n --arg branch "$BRANCH" '{branch: $branch}')")
wait_for $GATEWAY_APP_ID $GATEWAY_DEPLOYMENT
```

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

In the test, the four image applications were done after 111 s and the gateway 2 minutes later. On `failed`, read the build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`), with `?limit=5000` for all of it. CLI: `sevalla apps deployments trigger <app-id>` and `sevalla apps deployments get <deployment-id> --app-id <app-id>`. MCP: `trigger_deployment` and `get_deployment`.

**12. Check Dify**

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "$DIFY_URL/console/api/setup"
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"step":"not_started","setup_at":null}
```

### Set up Dify with its console API

The web interface does the first setup through Dify's console API, so a script can do it too. The console API isn't one of Dify's documented public APIs, and it can change between versions; these calls work with Dify 1.18.0. They continue in the same shell, with `ANTHROPIC_API_KEY` set and the workflow saved as `order-summary.yml`:

**1. Create the admin account**

`POST /console/api/init` checks `INIT_PASSWORD` and marks the session, `POST /console/api/setup` creates the account, and `POST /console/api/login` signs in with the password Base64-encoded. The cookie jar keeps the session:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
JAR=$(mktemp)
ADMIN_PASSWORD=$(openssl rand -hex 12)
curl -s -w '\n' -c "$JAR" -b "$JAR" -X POST "$DIFY_URL/console/api/init" -H "Content-Type: application/json" \
  -d "$(jq -n --arg password "$INIT_PASSWORD" '{password: $password}')"
curl -s -w '\n' -c "$JAR" -b "$JAR" -X POST "$DIFY_URL/console/api/setup" -H "Content-Type: application/json" \
  -d "$(jq -n --arg password "$ADMIN_PASSWORD" '{email: "owner@example.com", name: "Owner", password: $password}')"
curl -s -w '\n' -c "$JAR" -b "$JAR" -X POST "$DIFY_URL/console/api/login" -H "Content-Type: application/json" \
  -d "$(jq -n --arg password "$(printf '%s' "$ADMIN_PASSWORD" | base64)" '{email: "owner@example.com", password: $password}')"
```

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

Use your own email address, and keep `ADMIN_PASSWORD`.

**2. Install the Anthropic plugin and add the key**

Console calls send the `__Host-csrf_token` cookie's value in `X-CSRF-Token`. The plugin's identifier comes from the Marketplace: `curl -s https://marketplace.dify.ai/api/v1/plugins/langgenius/anthropic | jq -r .data.plugin.latest_package_identifier` prints the current one.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CSRF=$(awk '$6 == "__Host-csrf_token" {print $7}' "$JAR")
console() { curl -s -w '\n' -b "$JAR" -H "X-CSRF-Token: $CSRF" -H "Content-Type: application/json" "$@"; }
TASK_ID=$(console -X POST "$DIFY_URL/console/api/workspaces/current/plugin/install/marketplace" \
  -d '{"plugin_unique_identifiers": ["langgenius/anthropic:0.3.39@a9b013a5d1617da8db039acc0e0e1fb491832cd050aaa7d6acadf4fc06fe4c58"]}' | jq -r .task_id)
for i in $(seq 1 30); do
  STATUS=$(console "$DIFY_URL/console/api/workspaces/current/plugin/tasks/$TASK_ID" | jq -r '.task.status // empty')
  [ "$STATUS" = success ] || [ "$STATUS" = failed ] && break
  sleep 5
done
echo "$STATUS"
console -X POST "$DIFY_URL/console/api/workspaces/current/model-providers/langgenius/anthropic/anthropic/credentials" \
  -d "$(jq -n --arg key "$ANTHROPIC_API_KEY" '{credentials: {anthropic_api_key: $key}, name: "API key"}')"
```

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

**3. Import, publish and run the workflow**

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DIFY_APP_ID=$(console -X POST "$DIFY_URL/console/api/apps/imports" \
  -d "$(jq -n --rawfile yaml order-summary.yml '{mode: "yaml-content", yaml_content: $yaml}')" | jq -r .app_id)
console -X POST "$DIFY_URL/console/api/apps/$DIFY_APP_ID/workflows/publish" -d '{}' | jq -r .result
APP_KEY=$(console -X POST "$DIFY_URL/console/api/apps/$DIFY_APP_ID/api-keys" -d '{}' | jq -r .token)
curl -s -X POST "$DIFY_URL/v1/workflows/run" -H "Authorization: Bearer $APP_KEY" -H "Content-Type: application/json" \
  -d '{"inputs": {"items": "[{\"name\": \"Coffee mug\", \"qty\": 3, \"price\": 12.5}, {\"name\": \"Tea\", \"qty\": 2, \"price\": 4.99}]"}, "response_mode": "blocking", "user": "docs-check"}' \
  | jq -c '.data | {status, total: .outputs.total}'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
success
{"status":"succeeded","total":47.48}
```


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