Skip to content

Deploy to Railway

On Railway, each part of Plainwright becomes its own service in one project, built from your repository or from a public image, and the services reach each other over Railway’s private network. Use Railway when you want a managed platform with per-service scaling and no blueprint file. As on any platform without a shared disk, screenshots go to object storage and project file uploads aren’t available.

Service Source Start command Notes
Postgres Railway’s PostgreSQL template — Provides DATABASE_URL.
Redis Railway’s Redis template — Provides REDIS_URL. Must not evict keys.
backend Repo, root directory backend uvicorn app.main:app --host :: --port 8000 Migrates the database on start. Private only.
worker Repo, root directory backend, Dockerfile Dockerfile.worker rq worker test_runs Replicas = parallel runs.
scheduler Repo, root directory backend python -m app.services.scheduler Exactly one replica.
playwright-mcp Image mcr.microsoft.com/playwright:v1.44.0-jammy see step 5 Headless Chromium. Give it at least 2 GB of RAM.
web Repo root, Dockerfile deploy/web/Dockerfile — Frontend + reverse proxy. The only public service.

Railway’s private network may be IPv6-only, so every service that others call listens on ::.

The frontend calls the API at the relative path /api, so the UI and the API must share one origin. Add a small image to your copy of the repository that serves the frontend build and proxies /api/ and /screenshots/ to the backend, like nginx/nginx.conf does in Docker Compose:

  • Directorydeploy
    • Directoryweb
      • Dockerfile
      • default.conf.template
  • Directorybackend/
    • …
  • Directoryfrontend/
    • …
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;
}
}

BACKEND_HOST is the backend’s private host and port, set on the web service in step 6.

  1. Create a project and add PostgreSQL and Redis from Railway’s templates.

  2. Add shared variables (project settings → Shared Variables). Generate SECRET_KEY once; it must be identical on every app service. To change it later, use the rotation command, never an edit in place.

    Variable Value
    SECRET_KEY A random value of at least 32 characters: python3 -c "import secrets; print(secrets.token_urlsafe(48))"
    PLAINWRIGHT_ENV production, so the backend refuses to start with a missing or weak key
    APP_BASE_URL, CORS_ORIGINS The web service’s public URL, e.g. https://plainwright.up.railway.app (fill in after step 6)
    STORAGE_BACKEND s3
    S3_ENDPOINT_URL, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY Your bucket; see Object storage
    LOG_FORMAT json
  3. Add the backend service from your repository. In its settings, set the root directory to backend and the custom start command to uvicorn app.main:app --host :: --port 8000. Give it these variables:

    backend variables
    DATABASE_URL=${{Postgres.DATABASE_URL}}
    REDIS_URL=${{Redis.REDIS_URL}}
    PLAYWRIGHT_MCP_URL=http://playwright-mcp.railway.internal:8931/mcp
    SECRET_KEY=${{shared.SECRET_KEY}}
    PLAINWRIGHT_ENV=${{shared.PLAINWRIGHT_ENV}}
    APP_BASE_URL=${{shared.APP_BASE_URL}}
    CORS_ORIGINS=${{shared.CORS_ORIGINS}}
    STORAGE_BACKEND=${{shared.STORAGE_BACKEND}}
    S3_ENDPOINT_URL=${{shared.S3_ENDPOINT_URL}}
    S3_REGION=${{shared.S3_REGION}}
    S3_BUCKET=${{shared.S3_BUCKET}}
    S3_ACCESS_KEY_ID=${{shared.S3_ACCESS_KEY_ID}}
    S3_SECRET_ACCESS_KEY=${{shared.S3_SECRET_ACCESS_KEY}}
    LOG_FORMAT=${{shared.LOG_FORMAT}}

    Don’t give it a public domain; web reaches it privately.

  4. Add worker and scheduler the same way, with the same variables.

    • worker: root directory backend, variable RAILWAY_DOCKERFILE_PATH=Dockerfile.worker, start command rq worker test_runs, and RQ_REDIS_URL=${{Redis.REDIS_URL}} so the rq command finds Redis. Set the replica count to the number of tests to run at once.
    • scheduler: root directory backend, start command python -m app.services.scheduler, one replica.
  5. Add playwright-mcp from the Docker image mcr.microsoft.com/playwright:v1.44.0-jammy, with this start command. It’s the Docker Compose command with --host ::, and --allowed-hosts must stay last:

    Terminal window
    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 '*'"

    Name the service exactly playwright-mcp so playwright-mcp.railway.internal resolves, and don’t give it a public domain: the MCP server has no authentication.

  6. Add web from your repository with the root directory left at the repository root, RAILWAY_DOCKERFILE_PATH=deploy/web/Dockerfile and BACKEND_HOST=backend.railway.internal:8000. Generate a public domain for it on port 8080, then put that URL into the APP_BASE_URL and CORS_ORIGINS shared variables and redeploy the app services.

  7. Open the web URL and create the first account.

  • Scaling. Raise the worker replica count for more parallel runs, and give playwright-mcp more memory to match (about 0.5 GB per concurrent browser run). See Scaling workers.
  • Redis eviction. The queue must never drop keys. Check with redis-cli CONFIG GET maxmemory-policy and set noeviction if it isn’t already.
  • Updates. Pushing to the connected branch redeploys every service built from the repository. The backend migrates the database when it starts.
  • Firefox and WebKit. Add playwright-mcp-firefox / playwright-mcp-webkit services 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.
  • Postgres URL scheme. Plainwright needs postgresql://. If a database gives you postgres://, change the scheme.