Skip to content

Object storage (S3 / R2)

Every screenshot, visual-regression baseline and diff, and piece of run evidence goes through one storage layer, storage.py. By default it writes to a local folder. Switch it to an S3-compatible bucket when services run on different machines (Render, Railway, Fly.io, several backends), or to keep artifacts off the server’s disk.

Backend STORAGE_BACKEND Artifacts live in URL the app uses
Local (default) local SCREENSHOTS_DIR (/app/screenshots, the screenshots volume) /screenshots/<key>, served by the backend
S3-compatible s3 (aliases r2, minio) S3_BUCKET, under screenshots/<key> /api/files/<key>, which redirects to a presigned URL valid for one hour; or <S3_PUBLIC_BASE_URL>/screenshots/<key> when a public base URL is set

Keys are random 128-bit names such as 3f2a…9c.png; visual baselines and diffs go under visual/baselines/ and visual/diffs/.

R2 charges no egress fees, which suits screenshot-heavy use.

  1. In the Cloudflare dashboard, create an R2 bucket, e.g. plainwright-artifacts.
  2. Create an R2 API token with Object Read & Write on that bucket. Note the access key id, the secret and your account id.
  3. Set:
.env
STORAGE_BACKEND=s3
S3_ENDPOINT_URL=https://<account-id>.r2.cloudflarestorage.com
S3_REGION=auto
S3_BUCKET=plainwright-artifacts
S3_ACCESS_KEY_ID=<r2-access-key-id>
S3_SECRET_ACCESS_KEY=<r2-secret>
# Optional: a public r2.dev or custom-domain URL for the bucket.
S3_PUBLIC_BASE_URL=

Any other S3-compatible store (Backblaze B2, Wasabi, Tigris and so on) works the same way: its S3 endpoint, region, bucket and an access key.

  1. Put the values in .env, or in your platform’s environment. Both the backend and the workers need them: workers write artifacts, and the backend serves them.

  2. Apply them: docker compose up -d on Docker Compose; a redeploy elsewhere.

  3. Run a UI test and open its screenshot. With a private bucket, the image URL starts with /api/files/ and redirects to your bucket.

If the backend can’t use the bucket, check its log. STORAGE_BACKEND=s3 with an empty S3_BUCKET doesn’t fail: storage falls back to the local folder and logs “STORAGE_BACKEND=‘s3’ is unknown or not configured (S3_BUCKET empty) — using local storage”, so no evidence is lost to a typo.

  • Private bucket (recommended). Leave S3_PUBLIC_BASE_URL empty. The app links to /api/files/<key>; the backend answers with a redirect to a presigned URL that expires after an hour, so bucket links don’t stay valid forever.
  • Public bucket or CDN. Set S3_PUBLIC_BASE_URL to the bucket’s public URL (an r2.dev URL, a custom domain or a CDN). The app then links straight to <S3_PUBLIC_BASE_URL>/screenshots/<key> and the backend isn’t involved.

Plainwright deletes only one kind of artifact itself: a visual-regression baseline image that has been replaced or removed, once no run references it any more. Screenshots, diffs and evidence are kept forever. Add a lifecycle rule to the bucket that expires objects under screenshots/ after, say, 90 days. Keep in mind that current visual-regression baselines live under screenshots/visual/baselines/: if a rule deletes one, its visual check has nothing to compare against until a new baseline is approved. Either exclude that prefix or use a longer expiry for it.

Switching from local to s3 doesn’t break old runs: an artifact that isn’t in the bucket is still read from the local folder and served by the backend. To move the old files into the bucket anyway, copy the screenshots folder to the screenshots/ prefix, for example with the AWS CLI:

Terminal window
docker compose cp backend:/app/screenshots ./screenshots-export
aws s3 sync ./screenshots-export s3://plainwright-artifacts/screenshots/
# R2 or MinIO: add --endpoint-url https://<account-id>.r2.cloudflarestorage.com

Old runs keep their /screenshots/<key> links, which the backend serves from the local folder while it’s there. Keep the volume until those runs no longer matter.