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/.
Set up a bucket
Section titled “Set up a bucket”R2 charges no egress fees, which suits screenshot-heavy use.
- In the Cloudflare dashboard, create an R2 bucket, e.g.
plainwright-artifacts. - Create an R2 API token with Object Read & Write on that bucket. Note the access key id, the secret and your account id.
- Set:
STORAGE_BACKEND=s3S3_ENDPOINT_URL=https://<account-id>.r2.cloudflarestorage.comS3_REGION=autoS3_BUCKET=plainwright-artifactsS3_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=- Create a bucket with Block all public access on.
- Create an IAM user or role with this policy, replacing the bucket name:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::plainwright-artifacts/screenshots/*" } ]}s3:DeleteObject lets Plainwright remove visual baselines that were replaced and are no
longer referenced. Without it, those deletes fail quietly (a warning in the log) and the
old images stay in the bucket.
- Set (leave
S3_ENDPOINT_URLempty for AWS):
STORAGE_BACKEND=s3S3_ENDPOINT_URL=S3_REGION=eu-west-1S3_BUCKET=plainwright-artifactsS3_ACCESS_KEY_ID=<access-key-id>S3_SECRET_ACCESS_KEY=<secret>On AWS compute with an instance or task role, leave both key variables empty and boto3 uses the role.
Point Plainwright at your MinIO server’s S3 API. Path-style addressing is used
automatically whenever S3_ENDPOINT_URL is set.
STORAGE_BACKEND=s3S3_ENDPOINT_URL=http://minio:9000S3_REGION=us-east-1S3_BUCKET=plainwright-artifactsS3_ACCESS_KEY_ID=<minio-access-key>S3_SECRET_ACCESS_KEY=<minio-secret>S3_ENDPOINT_URL must be reachable from the backend and the workers. People’s browsers
also have to reach the presigned URLs, which point at the same host, so use an address
they can resolve, or set 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.
Turn it on
Section titled “Turn it on”-
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. -
Apply them:
docker compose up -don Docker Compose; a redeploy elsewhere. -
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.
Public bucket or presigned URLs
Section titled “Public bucket or presigned URLs”- Private bucket (recommended). Leave
S3_PUBLIC_BASE_URLempty. 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_URLto the bucket’s public URL (anr2.devURL, a custom domain or a CDN). The app then links straight to<S3_PUBLIC_BASE_URL>/screenshots/<key>and the backend isn’t involved.
Limit how long artifacts are kept
Section titled “Limit how long artifacts are kept”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.
Move existing artifacts
Section titled “Move existing artifacts”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:
docker compose cp backend:/app/screenshots ./screenshots-exportaws s3 sync ./screenshots-export s3://plainwright-artifacts/screenshots/# R2 or MinIO: add --endpoint-url https://<account-id>.r2.cloudflarestorage.comOld 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.
Next steps
Section titled “Next steps”- Configuration: every storage setting.
- Upgrades, migrations and backups: what else to back up.
- Security model: how artifact URLs and stored files are protected.