# How to Run Apache Airflow in a Cloud Sandbox

> Install Airflow with pip and its constraints file in a venv, run airflow standalone as a systemd service with the venv on PATH, and host port 8080 on a private URL. In our run, Airflow 3.3.2 installed in 6 seconds, was healthy 9 seconds after a cold boot, and a REST-triggered three-task DAG finished in 5 to 8 seconds.

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

**Short answer:** Install Airflow with pip and its constraints file in a venv, run airflow standalone as a systemd service with the venv on PATH, and host port 8080 on a private URL. In our run, Airflow 3.3.2 installed in 6 seconds, was healthy 9 seconds after a cold boot, and a REST-triggered three-task DAG finished in 5 to 8 seconds.

## What we ran

Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB, Ubuntu 24.04, Python 3.12.3). Airflow 3.3.2, installed the way the [Airflow quick start](https://airflow.apache.org/docs/apache-airflow/stable/start.html) shows. Timings are single runs. We then repeated steps 1 to 6 on a `small` sandbox (see [Which size](#which-size)).

| Step | Result |
| - | - |
| `uv pip install` with the constraints file | 6.3 s, 213 MB venv |
| `airflow db migrate` (SQLite) | 4.1 s |
| `airflow dags test` of a three-task DAG, no scheduler | 9.8 s wall, run took 4.9 s |
| `airflow standalone` under systemd, restart to healthy | about 8.5 s |
| Memory of the whole Airflow service | 957 MB |
| DAG run triggered over the REST API from outside | `success` in 5.3 to 8.3 s |
| `GET /api/v2/monitor/health` through the hosted URL | 0.20 s median over 10 calls |
| `sbx stop --wait`, then `sbx resume` | Memory restored, same PID, still healthy |
| Resume on a bigger size (boots from disk) | systemd started Airflow, healthy at 9 s uptime, run history kept |

`airflow standalone` runs the API server and UI, the scheduler, the DAG processor and the triggerer in one process tree, with SQLite and the `LocalExecutor`. Airflow's docs present it as a way to try Airflow, not as a production setup. That fits a sandbox: a private Airflow per branch, per agent or per experiment, which you delete when you're done.

## 1. Install Airflow with its constraints file

Airflow pins its dependency tree in a constraints file per version and Python minor version. Installing without it can pull versions Airflow wasn't tested with. `uv` is preinstalled in every sandbox; the install took 6.3 s in our run. Save as `install.sh`:

```bash
set -e
AIRFLOW_VERSION=3.3.2
PYTHON_VERSION="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
CONSTRAINT_URL="https://raw.githubusercontent.com/apache/airflow/constraints-${AIRFLOW_VERSION}/constraints-${PYTHON_VERSION}.txt"
uv venv ~/airflow-venv --python 3.12
. ~/airflow-venv/bin/activate
uv pip install "apache-airflow==${AIRFLOW_VERSION}" --constraint "${CONSTRAINT_URL}"
airflow version
```

```bash
sbx new --name airflow --type default
id=sb_g8mptg3wbr    # the id that sbx new printed

sbx scp ./install.sh $id:/home/user/
sbx exec $id --timeout 600 "bash install.sh 2>&1 | tail -3"
```

```text
 + wrapt==2.4.1
 + zipp==4.1.0
3.3.2
```

`sbx exec` stops a command after 30 seconds by default. This install took 6.3 seconds, but pass `--timeout 600` anyway: adding providers can take longer.

## 2. Write a DAG and test it without a scheduler

This DAG uses the Airflow 3 TaskFlow API (`airflow.sdk`). It makes a real HTTP call from inside the sandbox, passes the result between tasks through XCom, and writes a file. Save as `hello_pipeline.py`:

```python
from datetime import datetime
import json, platform, statistics, urllib.request

from airflow.sdk import dag, task


@dag(schedule="@hourly", start_date=datetime(2026, 10, 1), catchup=False, tags=["sandbox"])
def hello_pipeline():
    @task
    def extract() -> list[float]:
        # Real network call from inside the sandbox: wheel sizes of every apache-airflow release
        with urllib.request.urlopen("https://pypi.org/pypi/apache-airflow/json", timeout=20) as r:
            releases = json.load(r)["releases"]
        sizes = [f["size"] / 1e6 for files in releases.values() for f in files if f["packagetype"] == "bdist_wheel"]
        return sizes

    @task
    def transform(sizes: list[float]) -> dict:
        return {
            "wheels": len(sizes),
            "median_mb": round(statistics.median(sizes), 2),
            "max_mb": round(max(sizes), 2),
            "host": platform.node(),
        }

    @task
    def load(summary: dict) -> None:
        with open("/home/user/airflow/report.json", "w") as f:
            json.dump(summary, f)
        print("report:", summary)

    load(transform(extract()))


hello_pipeline()
```

`airflow dags test` runs every task once in a single process, with no scheduler or webserver. It's a quick check that a DAG parses and works (9.8 s in our run), and the one to give a coding agent that is editing DAGs:

```bash
sbx exec $id "mkdir -p ~/airflow/dags"
sbx scp ./hello_pipeline.py $id:/home/user/airflow/dags/
sbx exec $id --timeout 300 "export AIRFLOW_HOME=~/airflow AIRFLOW__CORE__LOAD_EXAMPLES=False
  . ~/airflow-venv/bin/activate
  airflow db migrate > /tmp/migrate.log 2>&1
  airflow dags test hello_pipeline 2>&1 | grep -E 'report:|DagRun Finished'"
```

```text
report: {'wheels': 289, 'median_mb': 5.25, 'max_mb': 13.68, 'host': 'cloudy-fancy-sapphire'}
… DagRun Finished: dag_id=hello_pipeline, … run_duration=4.895654, state=success, run_type=manual, …
```

Its exit code is non-zero when a task fails, so an agent can loop on it: edit the DAG, run `dags test`, read the log, repeat.

## 3. Run Airflow standalone as a systemd service

A systemd service starts again when the sandbox boots from disk, which a process started with `sbx exec --detach` doesn't. Save as `airflow.service`:

```ini
[Unit]
Description=Apache Airflow (standalone)
After=network-online.target
Wants=network-online.target

[Service]
User=user
WorkingDirectory=/home/user/airflow
Environment=AIRFLOW_HOME=/home/user/airflow
Environment=AIRFLOW__CORE__LOAD_EXAMPLES=False
Environment=PATH=/home/user/airflow-venv/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/user/airflow-venv/bin/airflow standalone
StandardOutput=append:/home/user/airflow/standalone.log
StandardError=inherit
Restart=on-failure
RestartSec=5
KillMode=mixed
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
```

```bash
sbx scp ./airflow.service $id:/home/user/
sbx exec $id "sudo mv airflow.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now airflow"
sbx exec $id --timeout 170 "until curl -sf -o /dev/null http://localhost:8080/api/v2/monitor/health; do sleep 1; done
  curl -s http://localhost:8080/api/v2/monitor/health"
```

```json
{"metadatabase":{"status":"healthy"},"scheduler":{"status":"healthy","latest_scheduler_heartbeat":"2026-10-27T09:41:59.811942+00:00"},"triggerer":{"status":"healthy",…},"dag_processor":{"status":"healthy",…}}
```

**The `PATH` line is required.** `airflow standalone` starts its components by running `airflow` from `PATH`. Our first unit left the line out. systemd reported the service `active (running)`, but nothing listened on 8080, and running it by hand showed why:

```text
Exception in thread dag-processor:
…
FileNotFoundError: [Errno 2] No such file or directory: 'airflow'
```

The parent process stays up when its children fail to start, so `Restart=on-failure` never fires. Check the health endpoint, not `systemctl is-active`.

Standalone creates an `admin` user and writes its generated password to `~/airflow/simple_auth_manager_passwords.json.generated`. Read it inside the sandbox when you need it. Don't paste it into logs or chat.

## 4. Open the UI on a private URL

Airflow binds to `0.0.0.0:8080`, so `sbx host` reaches it without `--localhost`. Keep it private, which is the default:

```bash
sbx host $id 8080 --title airflow
```

```json
{"ok":true,"type":"port.hosted","sandboxId":"sb_g8mptg3wbr","port":8080,"url":"https://cloudy-fancy-sapphire-8080.box.sandboxer.com?_token=<token>","access":"private",…}
```

```text
no token:              401
with ?_token=<token>:  302 (sets the __sbx_auth cookie)
UI with the cookie:    200
```

Open the full URL in a browser and sign in as `admin`. That makes two locks: the sandbox token keeps everyone else away from the page, and Airflow's own login sits behind it. Treat the URL and the cookie like passwords.

## 5. Trigger a DAG run over the REST API

Airflow 3's REST API lives under `/api/v2` and takes a JWT from `POST /auth/token`. Through a private hosted URL, a request needs both: the sandbox cookie to pass the proxy, and the bearer token for Airflow. This script ran on our laptop. It reads the admin password with `sbx exec` straight into a variable and never prints a secret:

```bash
URL="https://cloudy-fancy-sapphire-8080.box.sandboxer.com?_token=<token>"   # from sbx host
BASE=${URL%%\?*}
curl -s -o /dev/null -c jar "$URL"                       # 302: trade the token for the cookie

PW=$(sbx exec $id "python3 -c 'import json;print(json.load(open(\"/home/user/airflow/simple_auth_manager_passwords.json.generated\"))[\"admin\"])'")
JWT=$(curl -s -b jar -X POST $BASE/auth/token -H 'Content-Type: application/json' \
  -d "{\"username\":\"admin\",\"password\":\"$PW\"}" | jq -r .access_token); unset PW
api() { curl -s -b jar -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' "$@"; }

api -X PATCH $BASE/api/v2/dags/hello_pipeline -d '{"is_paused":false}' | jq -c '{dag_id,is_paused}'
RID=$(api -X POST $BASE/api/v2/dags/hello_pipeline/dagRuns -d '{"logical_date":null}' | jq -r .dag_run_id)
E=$(jq -rn --arg r "$RID" '$r|@uri')
until st=$(api "$BASE/api/v2/dags/hello_pipeline/dagRuns/$E" | jq -r .state); [ "$st" = success ] || [ "$st" = failed ]; do sleep 1; done
echo "state=$st"
api "$BASE/api/v2/dags/hello_pipeline/dagRuns/$E/taskInstances" | jq -c '.task_instances[] | {task_id,state,duration}'
api "$BASE/api/v2/dags/hello_pipeline/dagRuns/$E/taskInstances/transform/xcomEntries/return_value" | jq -c .value
```

```text
{"dag_id":"hello_pipeline","is_paused":false}
state=success
{"task_id":"extract","state":"success","duration":3.613}
{"task_id":"transform","state":"success","duration":0.138}
{"task_id":"load","state":"success","duration":0.074}
{"wheels":289,"median_mb":5.25,"max_mb":13.68,"host":"cloudy-fancy-sapphire"}
```

From the trigger call to `success` took 8.1 seconds, most of it the `extract` task's download from PyPI. Two later runs took 5.3 and 8.3 seconds.

New DAGs start paused. `"logical_date": null` is how Airflow 3 asks for a manual run with no data interval. Unpausing an `@hourly` DAG with `catchup=False` also queued one `scheduled` run for the latest interval (09:00 UTC), as the run list showed:

```json
{"run_type":"manual","state":"success","run_after":"2026-10-27T09:42:31.654995Z"}
{"run_type":"scheduled","state":"success","run_after":"2026-10-27T09:00:00Z"}
```

An agent that already drives the sandbox through `sbx exec` can skip the REST API and the password. The Airflow CLI inside the sandbox talks to the same database:

```bash
sbx exec $id "export AIRFLOW_HOME=~/airflow PATH=~/airflow-venv/bin:\$PATH; airflow dags trigger hello_pipeline -o plain 2>/dev/null"
```

```text
conf    dag_id          dag_run_id                                …
{}      hello_pipeline  manual__2026-10-27T09:43:50.422068+00:00  …
```

## 6. Keep the scheduler running for schedules

A schedule fires only while the scheduler runs. A stopped or paused sandbox runs nothing, and a run that was due while it slept is handled by `catchup` when it wakes, like any Airflow restart. Two Sandboxer timers can stop it:

- **Auto-stop.** Every sandbox has a deadline, one hour by default ([auto-stop](/docs/long-running#auto-stop)).
- **Idle auto-pause.** A sandbox with no activity pauses itself ([idle auto-pause](/docs/long-running#idle-auto-pause-and-wake-on-use)). A request to the private URL that carries its token wakes it, but a schedule inside the VM can't.

For a sandbox whose job is to run schedules, turn both off, and stop it yourself when you're done:

```bash
sbx idle $id 0
sbx extend $id --no-auto-stop
```

Then check both settings:

```bash
sbx info $id --json | jq -c '.sandbox | {idleStopMinutes, archiveAfter, ttlSeconds}'
```

```json
{"idleStopMinutes":0,"archiveAfter":null,"ttlSeconds":null}
```

A sandbox with no auto-stop bills for every hour it runs, so this is the one case where you have to remember the `sbx stop` or `sbx delete` yourself.

## 7. What survives a stop and resume

```bash
sbx stop $id --wait     # 2.2 s
sbx resume $id          # 1.4 s, "memoryRestored": true
sbx exec $id "systemctl show airflow -p MainPID; curl -s http://localhost:8080/api/v2/monitor/health | jq -c '{scheduler: .scheduler.status, dag_processor: .dag_processor.status}'"
```

```text
MainPID=1356
{"scheduler":"healthy","dag_processor":"healthy"}
```

The PID was the same as before the stop: memory was restored, so nothing restarted. The private URL and its cookie kept working with no new `sbx host` call, and the next REST-triggered run succeeded in 5.3 seconds.

A resume boots from disk instead when you change the size or no machine with the same CPU type is free. We forced that with `sbx resume $id --type large` (`memoryRestored: false`, `resumeReason: "resized"`). systemd started Airflow on boot:

```text
2.16                    # uptime when our first command ran
active
9.23                    # uptime when /health answered
{"scheduler":"healthy","triggerer":"healthy","dag_processor":"healthy"}
```

The SQLite database (776 KB after five runs), logs, DAG files and `report.json` all live in `~/airflow` on the sandbox disk. Every earlier run was still in the run list, and a new run succeeded in 8.3 seconds. Moving to a smaller size isn't supported yet. To shrink, create a new sandbox and copy `~/airflow` across. See [snapshots](/docs/snapshots#resume) for the rules.

## Which size

We ran steps 1 to 6 a second time on a `small` sandbox (2 vCPU, 4 GB), which bills at half the `default` rate. Everything worked:

| | `default` (4 vCPU, 8 GB) | `small` (2 vCPU, 4 GB) |
| - | - | - |
| Install | 6.3 s | 6.3 s |
| `airflow dags test`, including start-up | 9.8 s | 14.6 s |
| Restart or start to healthy | about 8.5 s | 12.4 s (first start) |
| Airflow service memory, idle after runs | 957 MB | 1.18 GB |
| REST-triggered run to `success` | 5.3 to 8.3 s | 8.7 s |

On `small`, the whole VM used 1.5 GB of its 3.9 GB after two runs. That leaves room for light tasks. Use `default` when your tasks load real data into memory: under the `LocalExecutor`, each running task is its own process in the same VM.

## Clean up

```bash
sbx delete $id --yes
```

A `default` sandbox costs $0.036/hour while it runs and nothing while it is stopped.

## FAQ

### Why does systemd say Airflow is running but port 8080 doesn't answer?

`airflow standalone` starts its components as `airflow` subprocesses found on `PATH`. systemd's default `PATH` doesn't include your venv, so they fail with `FileNotFoundError: 'airflow'` while the parent keeps running. Add `Environment=PATH=/home/user/airflow-venv/bin:…` to the unit.

### Can I use this setup in production?

Airflow's docs present standalone with SQLite as a quick start, not a production deployment. For a long-lived scheduler, follow Airflow's [production deployment guide](https://airflow.apache.org/docs/apache-airflow/stable/administration-and-deployment/production-deployment.html), which starts with a Postgres or MySQL metadata database. We tested only standalone with SQLite.

### How do I test a DAG without starting the scheduler?

Run `airflow dags test <dag_id>`. It runs every task once in one process and exits non-zero on failure. Our three-task DAG took 9.8 seconds, including Airflow's start-up.

### Do scheduled runs happen while the sandbox is stopped?

No. Nothing runs in a stopped or paused sandbox. Turn off idle auto-pause and auto-stop on a sandbox whose job is to run schedules, and set `catchup` on each DAG to decide what happens to intervals that were missed.

### Do I lose my DAG runs when the sandbox stops?

No. With memory restored, Airflow keeps running as if nothing happened. On a boot from disk, systemd starts it again and the SQLite database in `~/airflow` still holds every run.

## Related

- [Run a long job in a cloud sandbox and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox)
- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use)
- [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox)
