Skip to content

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.

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 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
backend/fly.backend.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"
backend/fly.worker.toml
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"
backend/fly.scheduler.toml
app = "plainwright-scheduler"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[processes]
app = "python -m app.services.scheduler"
[[vm]]
memory = "512mb"
fly.mcp.toml
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"
fly.web.toml
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
deploy/web/Dockerfile
# Frontend build + reverse proxy, so the UI and the API share one public origin.
# Build context: the repository root.
FROM node:20-alpine AS build
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY 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
deploy/web/default.conf.template
# 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.

  1. Create the apps.

    Terminal window
    for app in plainwright-backend plainwright-worker plainwright-scheduler plainwright-mcp plainwright-web; do
    fly apps create "$app"
    done
  2. Create the data services and note their connection strings.

    Terminal window
    fly mpg create # or any Postgres 15+; copy the connection string
    fly redis create # answer "no" to eviction; copy the redis:// URL
    fly storage create # Tigris bucket; copy the keys and bucket name

    Use the postgresql:// scheme in DATABASE_URL. If a provider prints postgres://, change it.

  3. Set the same secrets on the backend, worker and scheduler. SECRET_KEY must 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 2
    export REDIS_URL="redis://…" # from step 2
    for app in plainwright-backend plainwright-worker plainwright-scheduler; do
    fly 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=json
    done

    Keep a copy of SECRET_KEY somewhere safe: it encrypts every stored provider key and secret.

  4. Deploy the backend apps from the backend folder, then the other two from the repository root.

    Terminal window
    cd backend
    fly deploy --config fly.backend.toml
    fly deploy --config fly.worker.toml
    fly deploy --config fly.scheduler.toml
    cd ..
    fly deploy --config fly.mcp.toml
    fly deploy --config fly.web.toml
  5. 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-scheduler
    fly scale count 2 -a plainwright-worker
  6. Open https://plainwright-web.fly.dev and create the first account. With a custom domain, run fly certs add <domain> -a plainwright-web and update APP_BASE_URL and CORS_ORIGINS.

  • Scaling. fly scale count N -a plainwright-worker for more parallel runs, and fly scale memory 4096 -a plainwright-mcp to give the browser room for them (about 0.5 GB per concurrent browser run). See Scaling workers.
  • Updates. Run the same fly deploy commands. 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. With LOG_FORMAT=json every line is one JSON object.
  • Firefox and WebKit. Add plainwright-mcp-firefox / plainwright-mcp-webkit apps with --browser firefox or --browser webkit (WebKit needs playwright install --with-deps webkit) and set PLAYWRIGHT_MCP_URL_FIREFOX / PLAYWRIGHT_MCP_URL_WEBKIT on the worker.