RenderScreenshot can copy each screenshot it renders into an Amazon S3 bucket that you own, with no upload code in your app. This page covers the AWS side: a CloudFormation template for the bucket and a write-only IAM user, the ACL rules that trip up new buckets, and a preflight script that makes the same S3 calls as our upload job. It belongs to the integrations hub; every storage field is described in the storage parameter reference.
Paid plans only. Custom storage works on Starter, Growth and Scale. On Free, a request that sends a
storageblock fails withfeature_not_available. See pricing.Last tested: 2026-10-18.
cfn-lint1.57.2 validated the template. The preflight script ran with Python 3.14.8 and boto3 1.43.114 against moto 5.2.4 with IAM policy checks turned on, using the policy from the template. We did not deploy to a live AWS account, so there are no console screenshots, and theAccessControlListNotSupportedpath was not reproduced (moto accepts ACLs on any bucket). The ACL rules below come from the AWS Object Ownership documentation, checked on 2026-10-18.
How the copy works
flowchart LR A[Your app] -->|POST /v1/screenshot| B[RenderScreenshot API] B -->|image| A B --> C[(RenderScreenshot R2)] C -->|background job: PutObject| D[(Your S3 bucket)] D -->|optional, OAC| E[CloudFront]
The API writes each render to our own R2 storage, answers your request, and then queues a job that sends one PutObject to your bucket, with the image's content type and the ACL from the request or the dashboard default. So:
- Your bucket never slows the API response, and an upload error never turns a 200 into an error.
- Only fresh renders are copied. A cache hit does not upload again.
- The key needs write access only. We never read, list objects in, or delete from your bucket.
What you can build
- OG images behind your own CDN. Store social cards under
og/and serve them through CloudFront, so the image URL in yourog:imagetag outlives the cache link (30 days at most). - An audit archive with an expiry date. Capture pages daily under
archive/{domain}/{year}/{month}/{day}/and add an S3 lifecycle rule that moves old captures to a colder storage class or deletes them. The website archiving use case covers the capture side. - An event-driven pipeline. An S3 event notification on
s3:ObjectCreated:*starts a Lambda function or an SQS message for each new screenshot, for example to run a visual diff. To write to S3 from your own code instead, see the AWS Lambda integration.
Prerequisites
- A RenderScreenshot account on a paid plan, and access to the storage dashboard (Dashboard, then Storage).
- An AWS account where you can create a bucket and an IAM user, and the AWS CLI if you deploy the template from a terminal.
- Python 3.9 or later with
boto3for the preflight script.
Step 1: deploy the bucket and IAM user
The template makes a private bucket with all four Block Public Access settings on and SSE-S3 encryption. It sets Object Ownership to Bucket owner preferred, not the AWS default, because our upload always sends an ACL header (the dashboard default is private), and AWS documents that a bucket with ACLs disabled accepts only uploads with no ACL or with bucket-owner-full-control. The uploader is in your own account, so you still own every object. It adds an IAM user whose inline policy can list the bucket and write only under KeyPrefix. With ServePublicly=true, it also creates a CloudFront distribution that reads the bucket through Origin Access Control (OAC), and a bucket policy that lets only that distribution read objects under the prefix.
AWSTemplateFormatVersion: '2010-09-09' Description: > Private S3 bucket and a least-privilege IAM user for RenderScreenshot custom storage. Optionally serves one key prefix through CloudFront. Parameters: BucketName: Type: String Description: Globally unique bucket name, for example acme-screenshots KeyPrefix: Type: String Default: screenshots/ AllowedPattern: '^[a-z0-9][a-z0-9/_-]*/$' Description: Every path template you use must start with this prefix (ends with /) ServePublicly: Type: String Default: 'false' AllowedValues: ['true', 'false'] Description: Create a CloudFront distribution that serves the prefix Conditions: Public: !Equals [!Ref ServePublicly, 'true'] Resources: ScreenshotBucket: Type: AWS::S3::Bucket Properties: BucketName: !Ref BucketName OwnershipControls: Rules: # Accepts the ACL header that every upload sends. Block Public # Access below still rejects and ignores public ACLs. - ObjectOwnership: BucketOwnerPreferred PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true BucketEncryption: ServerSideEncryptionConfiguration: - ServerSideEncryptionByDefault: SSEAlgorithm: AES256 UploaderUser: Type: AWS::IAM::User Properties: Policies: - PolicyName: renderscreenshot-storage PolicyDocument: Version: '2012-10-17' Statement: - Sid: ConnectionTest Effect: Allow Action: s3:ListBucket Resource: !GetAtt ScreenshotBucket.Arn - Sid: WriteUnderPrefix Effect: Allow Action: - s3:PutObject - s3:PutObjectAcl Resource: !Sub '${ScreenshotBucket.Arn}/${KeyPrefix}*' OriginAccessControl: Type: AWS::CloudFront::OriginAccessControl Condition: Public Properties: OriginAccessControlConfig: Name: !Sub '${BucketName}-oac' OriginAccessControlOriginType: s3 SigningBehavior: always SigningProtocol: sigv4 Distribution: Type: AWS::CloudFront::Distribution Condition: Public Properties: DistributionConfig: Enabled: true HttpVersion: http2and3 Origins: - Id: screenshots DomainName: !GetAtt ScreenshotBucket.RegionalDomainName OriginAccessControlId: !GetAtt OriginAccessControl.Id S3OriginConfig: OriginAccessIdentity: '' DefaultCacheBehavior: TargetOriginId: screenshots ViewerProtocolPolicy: redirect-to-https AllowedMethods: [GET, HEAD] # AWS managed policy "CachingOptimized" CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 ReadThroughCloudFront: Type: AWS::S3::BucketPolicy Condition: Public Properties: Bucket: !Ref ScreenshotBucket PolicyDocument: Version: '2012-10-17' Statement: - Sid: CloudFrontReadsPrefixOnly Effect: Allow Principal: Service: cloudfront.amazonaws.com Action: s3:GetObject Resource: !Sub '${ScreenshotBucket.Arn}/${KeyPrefix}*' Condition: StringEquals: AWS:SourceArn: !Sub 'arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}' Outputs: Bucket: Value: !Ref ScreenshotBucket Region: Value: !Ref AWS::Region UploaderUserName: Value: !Ref UploaderUser PublicUrlBase: Condition: Public Value: !Sub 'https://${Distribution.DomainName}'
Deploy it, then create the user's access key. The template does not create the key, so the secret never appears in stack outputs.
aws cloudformation deploy --stack-name renderscreenshot-s3 \ --template-file renderscreenshot-s3.yaml --capabilities CAPABILITY_IAM \ --parameter-overrides BucketName=acme-screenshots ServePublicly=true aws cloudformation describe-stacks --stack-name renderscreenshot-s3 --query 'Stacks[0].Outputs' aws iam create-access-key --user-name <UploaderUserName from the outputs>
If you build the bucket by hand in the console instead, copy the two statements under UploaderUser into an inline policy and replace the !GetAtt and !Sub values with your bucket ARN. Also set Object Ownership to Bucket owner preferred under the bucket's Permissions tab.
Why the prefix matters
The IAM policy is the real boundary on where screenshots go, because a per-request storage.path wins over the dashboard default. With s3:PutObject scoped to screenshots/*, a path such as ../other/{hash}.{ext} or backups/{hash}.{ext} gets AccessDenied. That error is permanent: the job records it and stops, and the screenshot response is unchanged. So every path template you use, in the dashboard or per request, must start with the prefix.
Step 2: run the preflight check
Run this with the new key before you paste it into the dashboard. It calls HeadBucket (the dashboard's connection test), then PutObject with your ACL, then checks that a write outside the prefix is refused.
"""s3_preflight.py: make the same S3 calls as RenderScreenshot's storage copy. Run it with the access key you paste into the storage dashboard, so it sees exactly the permissions the upload job will have. pip install boto3 AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... \ S3_BUCKET=acme-screenshots S3_REGION=eu-west-1 S3_PREFIX=screenshots/ \ S3_ACL=private PUBLIC_BASE=https://d111111abcdef8.cloudfront.net \ python3 s3_preflight.py """ import os import sys import time import urllib.error import urllib.request import boto3 from botocore.exceptions import ClientError, EndpointConnectionError BUCKET = os.environ["S3_BUCKET"] REGION = os.environ.get("S3_REGION") or "us-east-1" # the dashboard's default too PREFIX = os.environ.get("S3_PREFIX", "") ACL = os.environ.get("S3_ACL", "private") # the ACL set in the dashboard PUBLIC_BASE = os.environ.get("PUBLIC_BASE", "").rstrip("/") s3 = boto3.client("s3", region_name=REGION, endpoint_url=os.environ.get("S3_ENDPOINT")) HINTS = { "403": "Wrong key, or the key cannot list the bucket. Allow s3:ListBucket on arn:aws:s3:::BUCKET (no /*).", "404": "No bucket with this name. Names are case-sensitive.", "301": "The bucket is in another region. Set S3_REGION (and the dashboard Region) to it.", "400": "Malformed request. Check the region name.", "AccessControlListNotSupported": "The bucket has ACLs disabled and refused this ACL. " "Use a policy for public access, or switch Object Ownership to 'Bucket owner preferred'.", "AccessDenied": "The key cannot write here. Allow s3:PutObject and s3:PutObjectAcl on " "arn:aws:s3:::BUCKET/PREFIX*, and kms:GenerateDataKey if the bucket uses SSE-KMS.", } def explain(step, err): code = err.response["Error"].get("Code", "?") if step == "HeadBucket" and code == "AccessDenied": code = "403" # AWS sends a bare 403 for HeadBucket; some emulators name it hint = HINTS.get(code, err.response["Error"].get("Message", "")) if code == "301": region = err.response["ResponseMetadata"]["HTTPHeaders"].get("x-amz-bucket-region") hint += f" The bucket says: {region}." if region else "" print(f"FAIL {step}: {code}. {hint}".replace("BUCKET", BUCKET).replace("PREFIX", PREFIX)) sys.exit(1) # 1. The dashboard's connection test is a HeadBucket call. try: s3.head_bucket(Bucket=BUCKET) print(f"ok HeadBucket {BUCKET} ({REGION})") except ClientError as err: explain("HeadBucket", err) except EndpointConnectionError as err: print(f"FAIL HeadBucket: cannot reach the endpoint: {err}") sys.exit(1) # 2. Each upload is one PutObject that carries the ACL. key = f"{PREFIX}rs-preflight-{int(time.time())}.txt" try: s3.put_object(Bucket=BUCKET, Key=key, Body=b"renderscreenshot preflight\n", ContentType="text/plain", ACL=ACL) print(f"ok PutObject s3://{BUCKET}/{key} with ACL {ACL}") except ClientError as err: explain("PutObject", err) # 3. With a prefix, writes outside it should be refused: that is what keeps a # per-request storage.path inside the folder you chose. if PREFIX: try: s3.put_object(Bucket=BUCKET, Key="rs-preflight-outside.txt", Body=b"x", ACL=ACL) print("WARN the key can also write outside the prefix. Scope s3:PutObject to PREFIX*." .replace("PREFIX", PREFIX)) except ClientError as err: if err.response["Error"]["Code"] != "AccessDenied": explain("PutObject outside prefix", err) print("ok writes outside the prefix are denied") # 4. Optional: the public URL the dashboard builds is PUBLIC_BASE + "/" + key. if PUBLIC_BASE: url = f"{PUBLIC_BASE}/{key}" try: with urllib.request.urlopen(url, timeout=15) as res: print(f"ok public URL answers {res.status}: {url}") except urllib.error.HTTPError as err: print(f"FAIL public URL answers {err.code}: {url}. Check the bucket policy or CDN origin.") sys.exit(1) except urllib.error.URLError as err: print(f"FAIL public URL unreachable: {url} ({err.reason})") sys.exit(1) print(f"Done. Delete s3://{BUCKET}/{key} with an admin key when you no longer need it.")
A passing run prints three ok lines, or four when PUBLIC_BASE is set. Each failure names the S3 error code and the policy or setting to change. The script leaves one small text file under the prefix, because the uploader key has no delete permission; remove it with an admin key.
Step 3: fill in the storage dashboard
| Dashboard field | Amazon S3 value | Where to find it |
|---|---|---|
| Bucket Name | acme-screenshots |
Bucket output |
| Endpoint URL | Leave blank | Blank means Amazon S3 |
| Region | eu-west-1 |
Region output. A blank field means us-east-1 |
| Access Key ID / Secret Access Key | The new key | aws iam create-access-key |
| Path Template | screenshots/{year}/{month}/{hash}.{ext} |
Must start with KeyPrefix and contain {hash} and {ext} |
| Default ACL | Private | See the next section |
| Public URL Base | https://d111111abcdef8.cloudfront.net |
PublicUrlBase output, or your CloudFront alias |
Tick Enable custom storage and save. Storage turns on only after the connection test passes.
ACLs, Object Ownership and Block Public Access
Our upload sends the ACL on every PutObject: the request's storage.acl if set, else the dashboard default. S3 decides whether to accept it with two bucket settings.
| Goal | Object Ownership | Block Public Access | Dashboard ACL | Readers get files from |
|---|---|---|---|---|
| Private archive | Bucket owner preferred | All on | Private | Your app, with presigned URLs |
| Public through a CDN (template default) | Bucket owner preferred | All on | Private | CloudFront with OAC |
| Public objects straight from S3 | Bucket owner preferred | BlockPublicAcls and IgnorePublicAcls off |
Public Read | https://<bucket>.s3.<region>.amazonaws.com/<key> |
Two rules from the AWS documentation explain most failures. A bucket with ACLs disabled (Bucket owner enforced, the default for new buckets) accepts only uploads with no ACL or with a bucket-owner-full-control ACL; others fail with AccessControlListNotSupported. And with BlockPublicAcls on, any upload that carries a public ACL is rejected. So public-read fails on a default bucket in two ways. If you made the bucket by hand, switch Object Ownership to Bucket owner preferred, then run the preflight with the same S3_ACL as your dashboard default.
Limits and costs on AWS
S3 bills each PutObject request, storage per GB-month and transfer out; CloudFront bills its own requests and transfer. See the AWS pricing pages for current rates. For images that crawlers fetch often, compare the egress model in the Cloudflare R2 integration.
Errors and retries
- Throttling (
SlowDown), 5xx and network errors: retried up to 5 attempts with growing waits. AccessDenied,NoSuchBucket,AccessControlListNotSupported: recorded once, not retried. Fix the setting and render the page again.- Invalid
storage.path: rejected before the render with HTTP 400invalid_parameter.
Troubleshooting
- Connection test shows
Forbidden: the key is wrong, ors3:ListBucketis missing on the bucket ARN. The ARN must have no/*. - Connection test shows
Http301Error: the Region field does not match the bucket's region. The preflight prints the region S3 reports. - Test passes, no files arrive: check that the path starts with the allowed prefix, that the requests are fresh renders, and that the ACL is accepted (run the preflight with the same
S3_ACL). - Bucket default encryption is SSE-KMS: add
kms:GenerateDataKeyon the key to the uploader policy. - CloudFront returns 403: the object is outside the prefix in the bucket policy, or the distribution ID in the policy condition is wrong.
Create an account, upgrade to a paid plan, and point the storage dashboard at the bucket from step 1.