OG Image on AWS S3: Host and Serve Social Preview Images from S3

How to host your og:image files on AWS S3 and serve them correctly — covering bucket policy, CORS, HTTPS via CloudFront, cache headers, and common access errors that break social previews.

Updated March 2026

Why S3-Hosted OG Images Fail

S3 is a popular place to store OG images, but several misconfigurations cause social previews to break silently:

  • Private bucket: S3 objects default to private. Social crawlers receive a 403 Forbidden and display no image.
  • HTTP not HTTPS: Direct S3 URLs (http://bucket.s3.amazonaws.com/...) are HTTP, not HTTPS. Mobile apps and strict scrapers block mixed content.
  • Wrong CORS headers: Some crawlers check CORS; S3 doesn't send CORS headers by default unless configured.
  • Missing Cache-Control: Without proper cache headers, CDNs won't cache your images efficiently and preview loads will be slow.

Fix 1: Make Bucket Objects Public (or Use Signed URLs)

The simplest approach: allow public read access on your OG image objects.

Option A: Public bucket policy (good for a dedicated OG images bucket):

// S3 Bucket Policy (paste in S3 console → Permissions → Bucket Policy)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadOgImages",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/og-images/*"
    }
  ]
}

Replace YOUR-BUCKET-NAME with your actual bucket name. Also make sure "Block public access" is turned off in the bucket settings for this to take effect.

Option B: Pre-signed URLs (for private buckets — not recommended for OG images since they expire and scrapers may hit the URL after expiry).

Fix 2: Serve via CloudFront for HTTPS

Direct S3 URLs are HTTP. Social crawlers require HTTPS. The correct solution is to put a CloudFront distribution in front of your S3 bucket — CloudFront serves content over HTTPS by default.

Quick setup via AWS CLI:

# Create a CloudFront distribution pointing to S3
aws cloudfront create-distribution   --origin-domain-name YOUR-BUCKET.s3.amazonaws.com   --default-root-object index.html

# Or use the AWS Console:
# CloudFront → Create Distribution → Origin domain: your-bucket.s3.amazonaws.com
# Viewer protocol: Redirect HTTP to HTTPS
# Cache policy: CachingOptimized

Once set up, your OG image URL becomes:

<!-- Instead of (HTTP, broken): -->
<meta property="og:image" content="http://my-bucket.s3.amazonaws.com/og/page.png" />

<!-- Use (HTTPS via CloudFront, working): -->
<meta property="og:image" content="https://d1234abcd.cloudfront.net/og/page.png" />

<!-- Or with a custom domain via Route 53: -->
<meta property="og:image" content="https://cdn.mysite.com/og/page.png" />

Fix 3: Set Correct Content-Type on Upload

If S3 objects were uploaded without the right Content-Type, crawlers may reject them. Set the correct MIME type on upload:

# Upload with correct Content-Type
aws s3 cp og-image.png s3://your-bucket/og/page.png   --content-type "image/png"   --cache-control "public, max-age=31536000"

# For JPEG
aws s3 cp og-image.jpg s3://your-bucket/og/page.jpg   --content-type "image/jpeg"   --cache-control "public, max-age=31536000"

# Bulk fix existing objects (set Content-Type on all PNGs in a prefix)
aws s3 cp s3://your-bucket/og/ s3://your-bucket/og/   --recursive   --content-type "image/png"   --metadata-directive REPLACE

Fix 4: CORS Configuration

Most social crawlers don't check CORS, but some browser-based tools do. Set a permissive CORS policy on the bucket:

// S3 CORS Configuration (S3 console → Permissions → Cross-origin resource sharing)
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedOrigins": ["*"],
    "ExposeHeaders": [],
    "MaxAgeSeconds": 3000
  }
]

Uploading OG Images from Node.js

If you're generating OG images dynamically (e.g., with Satori or Puppeteer) and then uploading to S3:

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: "us-east-1" });

async function uploadOgImage(slug: string, pngBuffer: Buffer): Promise<string> {
  const key = `og-images/${slug}.png`;

  await s3.send(new PutObjectCommand({
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    Body: pngBuffer,
    ContentType: "image/png",
    CacheControl: "public, max-age=31536000, immutable",
    // Optional: make it public (if bucket has public access enabled)
    ACL: "public-read",
  }));

  // Return CloudFront URL (not direct S3 URL)
  return `https://${process.env.CLOUDFRONT_DOMAIN}/${key}`;
}

Cache Headers for OG Images

Set Cache-Control: public, max-age=31536000, immutable on images that won't change. If you regenerate images for the same slug, either:

  • Use a new filename/path that includes a hash (e.g., og/article-slug-a1b2c3.png), or
  • Use a short cache TTL (max-age=86400) so stale images refresh within 24 hours, or
  • Invalidate CloudFront when you regenerate: aws cloudfront create-invalidation --distribution-id DIST_ID --paths "/og-images/article-slug.png"

Verify Your S3-Hosted OG Image

After configuring S3 + CloudFront, verify your site's social preview with OGFixer. It will show you whether the image loads correctly and how it looks in Twitter, LinkedIn, Discord, and Slack card previews.

Check your S3 OG image is loading →

Preview my OG image →