Save Screenshots to Amazon S3 with IAM and CloudFront

Copy every screenshot into your own Amazon S3 bucket: a least-privilege IAM policy, a CloudFormation template, ACL rules and a tested preflight script.

By Simon Chiu · Updated

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 storage block fails with feature_not_available. See pricing.

Last tested: 2026-10-18. cfn-lint 1.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 the AccessControlListNotSupported path 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 your og:image tag 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 boto3 for 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 400 invalid_parameter.

Troubleshooting

  • Connection test shows Forbidden: the key is wrong, or s3:ListBucket is 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:GenerateDataKey on 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.

Frequently asked questions

Which IAM permissions does RenderScreenshot need to save screenshots to S3?
Two statements. s3:ListBucket on the bucket ARN, because the dashboard's connection test is a HeadBucket call, and s3:PutObject plus s3:PutObjectAcl on the objects under your key prefix. No read or delete permission is needed.
Can I use the public-read ACL with a new S3 bucket?
Not by default. New buckets have Object Ownership set to Bucket owner enforced, which disables ACLs, and Block Public Access rejects public ACLs too. Keep the objects private and serve them through CloudFront, or turn off BlockPublicAcls and IgnorePublicAcls on purpose.
Why does the template set Object Ownership to Bucket owner preferred?
Every upload sends an ACL, private by default. AWS documents that a bucket with ACLs disabled accepts only uploads with no ACL or a bucket-owner-full-control ACL, so Bucket owner preferred is the setting that accepts every upload. Block Public Access stays on, so objects stay private.
What happens if the upload to S3 fails?
The screenshot response is not affected, because the copy runs in a background job after the API answers. Throttling, 5xx and network errors are tried up to 5 times in total, with backoff. AccessDenied and other permanent errors are recorded once and not retried.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.