How to Run Apache Airflow in a Cloud Sandbox
Updated October 27, 2026 .md
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 shows. Timings are single runs. We then repeated steps 1 to 6 on a small sandbox (see 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:
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 versionsbx 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" + wrapt==2.4.1
+ zipp==4.1.0
3.3.2sbx 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:
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:
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'"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:
[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.targetsbx 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"{"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:
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:
sbx host $id 8080 --title airflow{"ok":true,"type":"port.hosted","sandboxId":"sb_g8mptg3wbr","port":8080,"url":"https://cloudy-fancy-sapphire-8080.box.sandboxer.com?_token=<token>","access":"private",…}no token: 401
with ?_token=<token>: 302 (sets the __sbx_auth cookie)
UI with the cookie: 200Open 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:
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{"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:
{"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:
sbx exec $id "export AIRFLOW_HOME=~/airflow PATH=~/airflow-venv/bin:\$PATH; airflow dags trigger hello_pipeline -o plain 2>/dev/null"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).
- Idle auto-pause. A sandbox with no activity pauses itself (idle auto-pause). 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:
sbx idle $id 0
sbx extend $id --no-auto-stopThen check both settings:
sbx info $id --json | jq -c '.sandbox | {idleStopMinutes, archiveAfter, ttlSeconds}'{"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#
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}'"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:
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 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#
sbx delete $id --yesA 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, 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#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.