# How Do I Run Dify in a Cloud Sandbox on a Private URL?

> Clone Dify's release into a default Sandboxer sandbox, set CONSOLE_API_URL, CONSOLE_WEB_URL, SERVICE_API_URL, APP_API_URL, APP_WEB_URL and FILES_URL to the hosted address, bind nginx to 127.0.0.1:8080, run docker compose up -d and sbx host 8080 --localhost. Callers send the __sbx_auth cookie plus Dify's app key.

Source: https://sandboxer.com/guides/run-dify-in-a-sandbox. Updated 2026-10-27.

**Short answer:** Clone Dify's release into a default Sandboxer sandbox, set CONSOLE_API_URL, CONSOLE_WEB_URL, SERVICE_API_URL, APP_API_URL, APP_WEB_URL and FILES_URL to the hosted address, bind nginx to 127.0.0.1:8080, run docker compose up -d and sbx host 8080 --localhost. Callers send the __sbx_auth cookie plus Dify's app key.

## At a glance

One `default` sandbox (4 vCPU, 8 GB, 50 GB disk), Dify 1.18.0, October 27, 2026. Single runs, not benchmarks. No model provider was added: the test app is a workflow with a Code node, so it needs no LLM.

| Step | What we saw |
| - | - |
| `docker compose up -d` (pull 12 images, start 16 containers) | 62 seconds |
| Console API answering after `up -d` | 58 seconds |
| Memory with the stack idle | 2.8 GB used of 7.9 GB; `api`, `worker`, `worker_beat` and `api_websocket` about 500 MiB each |
| Disk | Images 8.0 GB; root disk went from 5.5 GB to 15 GB used |
| Private URL, no token | `401` from the Sandboxer proxy |
| Workflow API from a laptop, cookie + app key | `200`; median 303 ms over 20 calls (min 284, max 345) |
| `sbx stop --wait`, then `sbx resume` | 2.8 s and 2.0 s; `memoryRestored: true`, same container PIDs, API answered 0.3 s after resume returned |
| `sudo systemctl restart docker` | All containers came back (`restart: always`); API answered 48.6 s after the restart began |

## Size it

Dify's [Docker Compose guide](https://docs.dify.ai/en/getting-started/install-self-hosted/docker-compose) asks for at least 2 CPU cores and 4 GiB of RAM. `small` (2 vCPU, 4 GB) is exactly that minimum, and the idle stack already used 2.8 GB, so we used `default` ($0.036 per hour). Moving to a smaller size isn't supported yet, so pick the size up front. To get a smaller machine, create a new sandbox and copy `dify/docker/volumes` across.

```bash
sbx new --name dify --type default
id=sb_4odwgbje0l                 # from sbx new
# "subdomain":"broad-humble-crater"  ->  https://broad-humble-crater-8080.box.sandboxer.com
```

The hosted URL is `https://<subdomain>-<port>.box.sandboxer.com`, and the subdomain is in the `sbx new` output. It never changes across stop and resume, so you can write it into Dify's config before Dify starts.

## Configure Dify for the hosted URL

Dify builds absolute URLs from six variables in `docker/.env`. Per Dify's [environment variable reference](https://docs.dify.ai/en/self-host/deploy/configuration/environments):

| Variable | What Dify uses it for |
| - | - |
| `CONSOLE_API_URL` | Public URL of the backend API. Also decides whether cookies are `Secure`. |
| `CONSOLE_WEB_URL` | Public URL of the console. Email links and OAuth redirects. |
| `SERVICE_API_URL` | The "API Base URL" the console shows developers. |
| `APP_API_URL` | Backend URL the published web apps call. |
| `APP_WEB_URL` | Where published web apps live. |
| `FILES_URL` | File preview and download links. |

Behind Sandboxer's proxy, all six are the same HTTPS address, because Dify's nginx routes `/console/api`, `/api`, `/v1`, `/files` and the web pages on one port. We set them with a script (`setup.sh`, copied in with `sbx scp`):

```bash
set -e
git clone -q --depth 1 --branch "$(curl -s https://api.github.com/repos/langgenius/dify/releases/latest | jq -r .tag_name)" https://github.com/langgenius/dify.git
cd dify/docker
cp .env.example .env
URL=https://broad-humble-crater-8080.box.sandboxer.com
set_env() { sed -i "s|^$1=.*|$1=$2|" .env; }
for k in CONSOLE_API_URL CONSOLE_WEB_URL SERVICE_API_URL APP_API_URL APP_WEB_URL FILES_URL TRIGGER_URL; do set_env $k $URL; done
set_env NEXT_PUBLIC_SOCKET_URL wss://broad-humble-crater-8080.box.sandboxer.com
set_env ENDPOINT_URL_TEMPLATE "$URL/e/{hook_id}"
set_env EXPOSE_NGINX_PORT 127.0.0.1:8080
set_env EXPOSE_NGINX_SSL_PORT 127.0.0.1:8443
cat > docker-compose.override.yaml <<YAML
services:
  plugin_daemon:
    ports: !override
      - "127.0.0.1:5003:5003"
YAML
set_env DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN "$(openssl rand -base64 32)"
set_env SECRET_KEY "$(openssl rand -base64 42)"
```

```bash
sbx scp setup.sh $id:/home/user/setup.sh
sbx exec $id "bash setup.sh"
sbx exec $id --detach "cd dify/docker && docker compose up -d > ~/up.log 2>&1"
```

Notes from the run:

- **Clone shallow.** A full `git clone` of Dify ran past `sbx exec`'s default wait (exit `124`). With `--depth 1` it took 9 seconds.
- **`DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN` is required** in 1.18. Dify's guide says the agent backend doesn't start without it.
- **Loopback binding.** `EXPOSE_NGINX_PORT=127.0.0.1:8080` works because the compose file uses it as the host side of the port mapping. Port 80 is free for something else, and nothing reaches Dify except through the hosted URL.
- **Don't put `127.0.0.1:` in `EXPOSE_PLUGIN_DEBUGGING_PORT`.** We tried, and the `api` container went into a restart loop with: `PLUGIN_REMOTE_INSTALL_PORT must be a bare port number, got '127.0.0.1:5003'. A 'host:port' value usually means EXPOSE_PLUGIN_DEBUGGING_PORT was set to a compose publish spec like '127.0.0.1:5003'; bind loopback via a docker-compose.override.yaml instead`. The override with `ports: !override` above does that.

`docker compose up -d` pulled and started everything in 62 seconds. Database migrations run on the `api` container's first start, so poll until the console API answers:

```bash
sbx exec $id "until curl -sf http://127.0.0.1:8080/console/api/setup; do sleep 2; done"
# {"step":"not_started","setup_at":null}       (58 s after up -d)
```

## Host port 8080

```bash
sbx host $id 8080 --localhost --title dify
# https://broad-humble-crater-8080.box.sandboxer.com?_token=<token>
```

`--localhost` is needed because nginx listens on `127.0.0.1` only; the sandbox relays the connection (see [Hosting](/docs/hosting#apps-that-listen-on-localhost)). From a laptop:

```bash
B=https://broad-humble-crater-8080.box.sandboxer.com
curl -s -o /dev/null -w "%{http_code}\n" $B/console/api/setup
# 401
curl -s -D - -o /dev/null "$B/install?_token=<token>" | grep -iE "^HTTP|^location|^set-cookie"
# HTTP/2 302
# location: /install
# set-cookie: __sbx_auth=<token>; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax
```

In a browser, open `$B/install?_token=<token>` once. The cookie lasts 30 days, and the page redirects to Dify's admin setup.

## Set up the admin and sign in

The first account created on `/install` becomes the workspace owner. Do it while the port is private. We used the same endpoints the setup page calls, from a laptop, with the Sandboxer cookie in a header:

```bash
SBX="Cookie: __sbx_auth=<token>"
curl -s -H "$SBX" -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","name":"Admin","password":"<password>"}' $B/console/api/setup
# {"result":"success"}   (201)
```

Login was different. With the plain password, `/console/api/login` returned `401 {"code":"authentication_failed","message":"Invalid encrypted data"}`. Dify 1.18's login form sends the password base64-encoded, so a script must do the same:

```bash
curl -s -c jar.txt -D - -H "$SBX" -H "Content-Type: application/json" \
  -d "{\"email\":\"admin@example.com\",\"password\":\"$(printf %s '<password>' | base64)\",\"remember_me\":true}" \
  $B/console/api/login | grep -i set-cookie
# set-cookie: __Host-csrf_token=…; Max-Age=3600; Secure; Path=/; SameSite=Lax
# set-cookie: __Host-access_token=…; Max-Age=3600; Secure; HttpOnly; Path=/; SameSite=Lax
# set-cookie: __Host-refresh_token=…; Max-Age=2592000; Secure; HttpOnly; Path=/; SameSite=Lax
```

The `__Host-` prefix and `Secure` flag come from `CONSOLE_API_URL` starting with `https://`. To check, we blanked the six URL variables and recreated the containers: login then set plain `csrf_token`, `access_token` and `refresh_token` cookies without `Secure`, and the console reported `"api_base_url": null` for apps. With the variables set, it reported `https://broad-humble-crater-8080.box.sandboxer.com/v1`. Later console calls send Dify's cookies, the `__sbx_auth` cookie in the same `Cookie` header, and the CSRF token in `X-CSRF-Token`.

## Create an app that needs no model

Dify's chat and agent apps need a model provider. We didn't add one. Instead we imported a workflow app in Dify's DSL: Start takes `name`, a Python Code node builds a greeting, End returns it. The Code node runs in Dify's own `sandbox` container.

```yaml
app:
  name: hello-sandbox
  mode: workflow
  description: Greets the caller with a Code node. No model needed.
kind: app
version: 0.7.0
workflow:
  features: {}
  environment_variables: []
  conversation_variables: []
  graph:
    nodes:
      - id: start
        type: custom
        position: {x: 0, y: 0}
        data:
          type: start
          title: Start
          variables:
            - variable: name
              label: name
              type: text-input
              required: true
              max_length: 64
      - id: code
        type: custom
        position: {x: 300, y: 0}
        data:
          type: code
          title: Greet
          code_language: python3
          code: |
            def main(name: str) -> dict:
                return {"greeting": f"hello {name}", "length": len(name)}
          variables:
            - variable: name
              value_selector: [start, name]
          outputs:
            greeting: {type: string, children: null}
            length: {type: number, children: null}
      - id: end
        type: custom
        position: {x: 600, y: 0}
        data:
          type: end
          title: End
          outputs:
            - variable: greeting
              value_selector: [code, greeting]
            - variable: length
              value_selector: [code, length]
    edges:
      - id: start-code
        source: start
        target: code
        sourceHandle: source
        targetHandle: target
      - id: code-end
        source: code
        target: end
        sourceHandle: source
        targetHandle: target
```

Save it as `hello.yml`. In the console, use **Import DSL file**. Or call the endpoints the console uses: `POST /console/api/apps/imports` with `{"mode":"yaml-content","yaml_content":"…"}`, then `POST /console/api/apps/<app_id>/workflows/publish` and `POST /console/api/apps/<app_id>/api-keys`. The import answered `"status":"completed"`. With `version: 0.4.0` it answered `"completed-with-warnings"` and `"current_dsl_version":"0.7.0"`, so match the version your Dify reports. The key call returned `"token":"app-…"`.

## Call the app's API from a laptop

Two credentials go on every request: the Sandboxer cookie gets you through the proxy, and Dify's app key authorizes the call. Each check below hit `/v1/workflows/run`, the [workflow run endpoint](https://docs.dify.ai/en/api-reference/guides/get-started):

```bash
BODY='{"inputs":{"name":"Ada"},"response_mode":"blocking","user":"laptop-1"}'

curl -s -X POST -H "Authorization: Bearer app-…" -H "Content-Type: application/json" \
  -d "$BODY" -o /dev/null -w "%{http_code}\n" $B/v1/workflows/run
# 401   (Sandboxer: no cookie, Dify never sees it)

curl -s -X POST -H "$SBX" -H "Content-Type: application/json" -d "$BODY" $B/v1/workflows/run
# {"code":"unauthorized","message":"Authorization header must be provided and start with 'Bearer'","status":401}

curl -s -X POST -H "$SBX" -H "Authorization: Bearer app-…" -H "Content-Type: application/json" \
  -d "$BODY" $B/v1/workflows/run
```

```json
{"task_id": "fc1d67f2-…", "workflow_run_id": "c6f418f9-…", "data": {"status": "succeeded", "outputs": {"greeting": "hello Ada", "length": 3}, "error": null, "elapsed_time": 0.14384610700000167, "total_tokens": 0, "total_steps": 3, …}}
```

`total_tokens: 0` confirms no model ran. Twenty more blocking calls from the laptop (each a new TLS connection) all returned `200`, median 303 ms. `"response_mode":"streaming"` streamed Server-Sent Events (`workflow_started`, `node_started`, `text_chunk`, `node_finished`, `workflow_finished`) through the proxy unchanged.

The published web app at `$APP_WEB_URL/workflow/<code>` loaded (`<title>Dify</title>`, `200`) with the cookie and returned `401` without it. Anyone you share it with needs the token link first.

## What survives stop and resume

We noted the PIDs of the `api`, `nginx` and `db_postgres` containers, then:

```bash
sbx stop $id --wait     # 2.8 s
sbx resume $id          # 2.0 s
sbx info $id            # memoryRestored: true
```

While stopped, the URL returned `404`. After resume, a workflow run from the laptop succeeded 0.3 seconds after `sbx resume` returned. All three containers had the same PIDs and start times: nothing restarted. The hosted route and token were kept.

If a resume lands on a different CPU type, the sandbox boots from disk (files kept, processes lost), and `sbx info` shows `memoryRestored: false`. Then Docker, an enabled systemd service, starts at boot and brings back containers that have a restart policy. Every long-running service in Dify's compose file has `restart: always`. We didn't force a cold boot. To test that restart path, we restarted Docker inside the sandbox:

```bash
sbx exec $id "sudo systemctl restart docker"
# restart returned after 11856 ms
# v1/info 200 after 48560 ms
```

All 15 services came back by themselves, and the workflow returned `"hello after docker restart"`. The admin account, app and API key were still there, because they live in Postgres under `dify/docker/volumes`. We didn't time a real cold boot, so plan for at least this wait after one. The [Docker Compose guide](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) shows a real cold boot of a smaller stack.

## FAQ

### Do I need an OpenAI or other model key to try Dify?

No, for workflow apps built from Code, HTTP and template nodes, like the one above. Chat, agent and LLM nodes need a model provider. To keep it inside the sandbox, run a small model on CPU with [Ollama](/guides/run-ollama-on-cpu-in-a-cloud-sandbox) and add it as an Ollama provider in Dify. We didn't test that part.

### Why does the console call the wrong address, or show the wrong API base URL?

One of the URL variables is still empty or points at `localhost`. Set all six to `https://<subdomain>-8080.box.sandboxer.com`, then run `docker compose up -d` again so the changed containers are recreated. Dify's guide says to run `docker compose down` first after changing variables.

### Can a server call the Dify API without the Sandboxer cookie?

Not while the port is private. Send `Cookie: __sbx_auth=<token>` along with `Authorization: Bearer app-…`. If a caller can't set that header, `sbx host $id 8080 --localhost --public` makes the port public, and Dify's app keys and login are then the only guard. Switching back to private mints a new token.

### Does the URL change after stop or resume?

No. The subdomain and hosted routes are kept, so the six URL variables stay valid. A fork gets a new subdomain and no routes, so change the variables and host the port again there.

## Related

- [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service)
- [Run n8n in a cloud sandbox](/guides/run-n8n-in-a-cloud-sandbox)
- [Run Ollama on CPU in a cloud sandbox](/guides/run-ollama-on-cpu-in-a-cloud-sandbox)
- [Hosting](/docs/hosting)
