Deploy to Fly.io
On Fly.io, each Plainwright service is its own Fly app, and the apps talk over Fly’s private
IPv6 network as <app>.internal. Only the web app is public. Use Fly when you want machines
close to your users and per-service scaling from the command line. As on any platform without
a shared disk, screenshots go to object storage and project file uploads aren’t available.
The apps
Section titled “The apps”| Fly app | Built from | Process | Public |
|---|---|---|---|
plainwright-backend |
backend/Dockerfile |
uvicorn app.main:app --host :: --port 8000 |
No |
plainwright-worker |
backend/Dockerfile.worker |
rq worker test_runs |
No |
plainwright-scheduler |
backend/Dockerfile |
python -m app.services.scheduler |
No |
plainwright-mcp |
image mcr.microsoft.com/playwright:v1.44.0-jammy |
the MCP server, --host :: |
No |
plainwright-web |
deploy/web/Dockerfile |
nginx: frontend + proxy | Yes |
You also need Postgres 15 or newer (Fly Managed Postgres, or any provider Fly can reach), Redis
(fly redis create gives you Upstash Redis on Fly’s network) and an S3-compatible bucket
(fly storage create gives you Tigris).
App names are global on Fly. If one is taken, pick another and use it consistently in the
config files and .internal addresses below.
Add the config files
Section titled “Add the config files”Add these files to your copy of the repository. The web image is the same frontend-plus-proxy image as on Railway; its two files are repeated here.
Directorybackend
- fly.backend.toml
- fly.worker.toml
- fly.scheduler.toml
Directorydeploy
Directoryweb
- Dockerfile
- default.conf.template
- fly.mcp.toml
- fly.web.toml
app = "plainwright-backend"primary_region = "iad"
[build] dockerfile = "Dockerfile"
[processes] app = "uvicorn app.main:app --host :: --port 8000" # :: = Fly's private IPv6 network
[[vm]] memory = "1gb"app = "plainwright-worker"primary_region = "iad"
[build] dockerfile = "Dockerfile.worker"
[processes] app = "rq worker test_runs" # Redis URL from the RQ_REDIS_URL secret
[[vm]] memory = "1gb"app = "plainwright-scheduler"primary_region = "iad"
[build] dockerfile = "Dockerfile"
[processes] app = "python -m app.services.scheduler"
[[vm]] memory = "512mb"app = "plainwright-mcp"primary_region = "iad"
[build] image = "mcr.microsoft.com/playwright:v1.44.0-jammy"
[processes] # Same as docker-compose.yml, with --host ::. --allowed-hosts is variadic: keep it last. app = "bash -c \"npm install -g @playwright/mcp@latest && npx --package=@playwright/mcp@latest -c 'playwright install chromium' && npx @playwright/mcp@latest --port 8931 --host :: --headless --no-sandbox --browser chromium --isolated --allowed-hosts '*'\""
[[vm]] memory = "2gb"app = "plainwright-web"primary_region = "iad"
[build] dockerfile = "deploy/web/Dockerfile"
[env] BACKEND_HOST = "plainwright-backend.internal:8000"
[http_service] internal_port = 8080 force_https = true auto_stop_machines = "off" min_machines_running = 1# Frontend build + reverse proxy, so the UI and the API share one public origin.# Build context: the repository root.FROM node:20-alpine AS buildWORKDIR /appCOPY frontend/package*.json ./RUN npm ciCOPY frontend/ ./RUN npm run build
FROM nginx:alpineCOPY --from=build /app/dist /usr/share/nginx/htmlCOPY deploy/web/default.conf.template /etc/nginx/templates/default.conf.template# Makes the entrypoint export NGINX_LOCAL_RESOLVERS (the nameservers in /etc/resolv.conf).ENV NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1# The nginx image fills in ${...} from the environment when the container starts.server { listen 8080; server_tokens off; client_max_body_size 30m; root /usr/share/nginx/html;
# Re-resolve the backend's private address, so a redeployed backend doesn't cause 502s. resolver ${NGINX_LOCAL_RESOLVERS} valid=10s;
add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location = /metrics { return 404; }
location /api/ { set $backend ${BACKEND_HOST}; proxy_pass http://$backend$request_uri; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_buffering off; # live run streams proxy_read_timeout 3600s; }
location /screenshots/ { set $backend ${BACKEND_HOST}; proxy_pass http://$backend$request_uri; }
location / { try_files $uri $uri/ /index.html; }}fly deploy uploads the folder you run it from as the build context, so if you have a local
frontend/node_modules, add frontend/node_modules and frontend/dist to a .dockerignore at
the repository root to keep the upload small.
Deploy
Section titled “Deploy”-
Create the apps.
Terminal window for app in plainwright-backend plainwright-worker plainwright-scheduler plainwright-mcp plainwright-web; dofly apps create "$app"done -
Create the data services and note their connection strings.
Terminal window fly mpg create # or any Postgres 15+; copy the connection stringfly redis create # answer "no" to eviction; copy the redis:// URLfly storage create # Tigris bucket; copy the keys and bucket nameUse the
postgresql://scheme inDATABASE_URL. If a provider printspostgres://, change it. -
Set the same secrets on the backend, worker and scheduler.
SECRET_KEYmust be identical on all three. Change it later only with the rotation command.Terminal window export SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))')"export DATABASE_URL="postgresql://…" # from step 2export REDIS_URL="redis://…" # from step 2for app in plainwright-backend plainwright-worker plainwright-scheduler; dofly secrets set -a "$app" --stage \SECRET_KEY="$SECRET_KEY" PLAINWRIGHT_ENV=production DATABASE_URL="$DATABASE_URL" \REDIS_URL="$REDIS_URL" RQ_REDIS_URL="$REDIS_URL" \PLAYWRIGHT_MCP_URL="http://plainwright-mcp.internal:8931/mcp" \APP_BASE_URL="https://plainwright-web.fly.dev" CORS_ORIGINS="https://plainwright-web.fly.dev" \STORAGE_BACKEND=s3 S3_ENDPOINT_URL="https://fly.storage.tigris.dev" S3_REGION=auto \S3_BUCKET="…" S3_ACCESS_KEY_ID="…" S3_SECRET_ACCESS_KEY="…" LOG_FORMAT=jsondoneKeep a copy of
SECRET_KEYsomewhere safe: it encrypts every stored provider key and secret. -
Deploy the backend apps from the
backendfolder, then the other two from the repository root.Terminal window cd backendfly deploy --config fly.backend.tomlfly deploy --config fly.worker.tomlfly deploy --config fly.scheduler.tomlcd ..fly deploy --config fly.mcp.tomlfly deploy --config fly.web.toml -
Pin the scheduler to one machine (two would enqueue scheduled tests twice), and set the number of workers.
Terminal window fly scale count 1 -a plainwright-schedulerfly scale count 2 -a plainwright-worker -
Open
https://plainwright-web.fly.devand create the first account. With a custom domain, runfly certs add <domain> -a plainwright-weband updateAPP_BASE_URLandCORS_ORIGINS.
Operating on Fly
Section titled “Operating on Fly”- Scaling.
fly scale count N -a plainwright-workerfor more parallel runs, andfly scale memory 4096 -a plainwright-mcpto give the browser room for them (about 0.5 GB per concurrent browser run). See Scaling workers. - Updates. Run the same
fly deploycommands. The backend migrates the database on start; deploy the backend first, then the worker and scheduler, which share its code. - Logs.
fly logs -a plainwright-worker. WithLOG_FORMAT=jsonevery line is one JSON object. - Firefox and WebKit. Add
plainwright-mcp-firefox/plainwright-mcp-webkitapps with--browser firefoxor--browser webkit(WebKit needsplaywright install --with-deps webkit) and setPLAYWRIGHT_MCP_URL_FIREFOX/PLAYWRIGHT_MCP_URL_WEBKITon the worker.
Next steps
Section titled “Next steps”- Object storage: bucket settings and lifecycle rules.
- Sign-in and email: set the OAuth and SMTP secrets on the backend.
- Monitoring: Sentry and Prometheus metrics.