How to create an image CDN using AWS S3 and CloudFront
Put prebuilt images in a private S3 bucket and let a public CloudFront distribution serve them over HTTPS. The Node.js recipe below creates that setup, publishes a PNG under a content hash, checks the complete downloaded bytes, and provides a separate teardown command.
Why use a CDN for images
CloudFront can cache your images at its edge locations. S3 stores the originals; an origin access control (OAC) signs CloudFront’s requests to S3. The bucket policy permits reads from exactly this distribution while S3 Block Public Access stays enabled. AWS documents this combination for a regular S3 origin. The resulting CloudFront URLs are public. OAC protects the origin and does not authenticate viewers.
This setup delivers the bytes you upload. It does not resize images, convert formats, or interpret transformation query parameters. Prepare each image variant before publishing it.
Set up a standalone Node project
Use a POSIX shell with Node.js 26.8.1 or a newer release within Node.js 26, and a separately installed Corepack. For a new installation, choose the current security patch from the Node.js release list. The package manifest pins Yarn 4.12.0 and AWS SDK v3 clients 3.1073.0 for this example. Those SDK versions are reproduction pins, rather than a claim that they are the latest patches.
You also need an AWS account authorized to create, inspect, update, and delete an S3 bucket, its
objects and bucket policy, a CloudFront distribution, and an OAC. Verification also needs read access
to the managed cache policy and S3 public-access, ownership, and versioning settings. The script uses
the standard AWS partition and fixes the S3 Region to us-east-1. It requires AWS_ACCOUNT_ID, AWS_ACCESS_KEY_ID,
and AWS_SECRET_ACCESS_KEY in its environment; include AWS_SESSION_TOKEN for temporary
credentials. Supply them through your existing credential tooling. It does not load profiles or
fall back to another identity.
Start in a scratch directory outside any existing JavaScript project. The setup checks ancestors
before creating image-cdn, so a parent workspace or Yarn configuration cannot silently change its
installation. It refuses an existing destination and keeps your shell’s directory unchanged.
(
command -v node >/dev/null || { printf 'Install Node.js first.\n' >&2; exit 1; }
command -v corepack >/dev/null || { printf 'Install Corepack first.\n' >&2; exit 1; }
node --input-type=module <<'NODE'
import { existsSync } from 'node:fs'
import { mkdir, realpath, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
const [major, minor, patch] = process.versions.node.split('.').map(Number)
if (major !== 26 || minor < 8 || (minor === 8 && patch < 1)) {
throw new Error('Use Node.js 26.8.1 or a newer patch/minor in Node.js 26.')
}
for (let directory = await realpath('.'); ; directory = dirname(directory)) {
const projectFiles = ['package.json', '.yarnrc.yml', '.yarnrc', '.pnp.cjs', '.pnp.loader.mjs',
'yarn.lock', 'package-lock.json', 'pnpm-lock.yaml', '.npmrc']
if (projectFiles.some((name) => existsSync(join(directory, name)))) {
throw new Error('Start outside an existing JavaScript project or package-manager configuration.')
}
if (dirname(directory) === directory) break
}
await mkdir('image-cdn')
await writeFile('image-cdn/package.json', `${JSON.stringify({
name: 'image-cdn-example', private: true, type: 'module', packageManager: 'yarn@4.12.0',
dependencies: { '@aws-sdk/client-cloudfront': '3.1073.0', '@aws-sdk/client-s3': '3.1073.0' },
}, null, 2)}\n`, { flag: 'wx' })
await writeFile('image-cdn/.yarnrc.yml', 'nodeLinker: node-modules\n', { flag: 'wx' })
NODE
if [ "$?" -ne 0 ]; then exit 1; fi
cd image-cdn || exit 1
corepack yarn install
)
If installation fails after the project files were created, keep that directory and its manifest. Retry the installation from the same scratch directory:
(cd image-cdn && corepack yarn install)
Save a valid prebuilt PNG as image-cdn/image.png. This example accepts a nonempty file up to 5 MiB
and checks its PNG signature; it does not validate its decoded pixels. Input paths must contain only
printable ASCII characters and begin with ./ or /. Use an image you intend to
make public. Run one command at a time and keep cdn-state.json until teardown completes.
Save the deployment and verification program
Save this complete file as image-cdn/cdn.ts. It uses the regular regional S3 endpoint, always-sign
OAC, and the AWS-managed
CachingOptimizedForUncompressedObjects policy.
That policy has a 1-second minimum TTL, a 1-day default, and a 365-day maximum. Each uploaded image
sets Cache-Control: public, max-age=31536000, immutable.
Every SDK request has one attempt and a 6-second abort signal. Each CloudFront readiness wait has its own 20-minute window, including a separate window for disabling the distribution during teardown. Each anonymous fetch has a 30-second deadline through its response body. A slower valid transfer can therefore fail verification. An expired deployment window means the outcome is incomplete; it does not mean AWS has canceled the deployment.
import assert from 'node:assert/strict'
import { createHash, randomUUID } from 'node:crypto'
import { readFile, rename, stat, writeFile } from 'node:fs/promises'
import { addAbortSignal, Readable } from 'node:stream'
import { setTimeout as delay } from 'node:timers/promises'
import { parseArgs } from 'node:util'
import {
CloudFrontClient, CreateDistributionCommand, CreateOriginAccessControlCommand,
DeleteDistributionCommand, DeleteOriginAccessControlCommand, GetCachePolicyCommand,
GetDistributionCommand, GetOriginAccessControlCommand, UpdateDistributionCommand,
} from '@aws-sdk/client-cloudfront'
import {
CreateBucketCommand, DeleteBucketCommand, DeleteObjectCommand, GetBucketOwnershipControlsCommand,
GetBucketPolicyCommand, GetBucketVersioningCommand, GetObjectCommand, GetPublicAccessBlockCommand,
HeadBucketCommand, ListObjectsV2Command, PutBucketPolicyCommand, PutObjectCommand, S3Client,
} from '@aws-sdk/client-s3'
type State = {
owner: string
bucket: string
bucketCreated: boolean
keys: string[]
attemptedKey?: string
pending?: string
oacId?: string
distributionId?: string
distributionArn?: string
domain?: string
removed?: boolean
}
const region = 'us-east-1'
const cachePolicyId = 'b2884449-e4de-46a7-ac36-70bc7f1ddd6d'
const cacheControl = 'public, max-age=31536000, immutable'
const maxBytes = 5 * 1024 * 1024
const statePath = 'cdn-state.json'
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
class RecipeError extends Error {}
function check(condition: unknown, message: string): asserts condition {
if (!condition) throw new RecipeError(message)
}
function required(name: string): string {
const value = process.env[name]
check(value, `Set ${name} before running this command.`)
return value
}
function statusOf(error: unknown): number | undefined {
if (typeof error !== 'object' || error === null || !('$metadata' in error)) return
const metadata = error.$metadata
if (typeof metadata !== 'object' || metadata === null || !('httpStatusCode' in metadata)) return
return typeof metadata.httpStatusCode === 'number' ? metadata.httpStatusCode : undefined
}
function validState(value: unknown): value is State {
if (typeof value !== 'object' || value === null) return false
return 'owner' in value && typeof value.owner === 'string' && uuidPattern.test(value.owner)
&& 'bucket' in value && value.bucket === `image-cdn-${value.owner}`
&& 'bucketCreated' in value && typeof value.bucketCreated === 'boolean'
&& 'keys' in value && Array.isArray(value.keys)
&& value.keys.every((key: unknown) => typeof key === 'string' && /^images\/[a-f0-9]{64}\.png$/.test(key))
&& ['attemptedKey', 'pending', 'oacId', 'distributionId', 'distributionArn', 'domain'].every((key) =>
!(key in value) || typeof Reflect.get(value, key) === 'string')
&& (!('removed' in value) || typeof value.removed === 'boolean')
}
async function main(): Promise<void> {
const { positionals, values } = parseArgs({ allowPositionals: true, options: { id: { type: 'string' } } })
const [command, input, ...extra] = positionals
check(extra.length === 0 && ['deploy', 'publish', 'verify', 'destroy'].includes(command ?? ''),
'Usage: node cdn.ts deploy|publish|verify image.png [--id UUID], or node cdn.ts destroy')
check(command === 'deploy' || values.id === undefined, '--id is only accepted for deploy.')
check(command === 'destroy' ? input === undefined : input !== undefined, 'Supply exactly one PNG input, except for destroy.')
let bytes = Buffer.alloc(0)
if (input !== undefined) {
check(/^[\x20-\x7e]+$/.test(input) && (input.startsWith('./') || input.startsWith('/')),
'Use an ASCII input path beginning with ./ or /.')
const info = await stat(input).catch(() => { throw new RecipeError('Cannot read the PNG input.') })
check(info.isFile() && info.size > 0 && info.size <= maxBytes, 'Input must be a PNG file of 1 byte to 5 MiB.')
bytes = await readFile(input)
check(bytes.length <= maxBytes && bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')),
'Input must have a PNG signature and be at most 5 MiB. Prepare a valid PNG before upload.')
}
const digest = createHash('sha256').update(bytes).digest('hex')
const key = `images/${digest}.png`
const account = required('AWS_ACCOUNT_ID')
check(/^\d{12}$/.test(account), 'AWS_ACCOUNT_ID must be your 12-digit account ID.')
const credentials = {
accessKeyId: required('AWS_ACCESS_KEY_ID'), secretAccessKey: required('AWS_SECRET_ACCESS_KEY'),
sessionToken: process.env.AWS_SESSION_TOKEN,
}
const s3 = new S3Client({ region, endpoint: `https://s3.${region}.amazonaws.com`, credentials, maxAttempts: 1, followRegionRedirects: false })
const cf = new CloudFrontClient({ region: 'us-east-1', endpoint: 'https://cloudfront.amazonaws.com', credentials, maxAttempts: 1 })
const options = () => ({ abortSignal: AbortSignal.timeout(6_000) })
let state: State
async function save(): Promise<void> {
await writeFile(`${statePath}.tmp`, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
await rename(`${statePath}.tmp`, statePath)
}
async function mutate<T>(phase: string, action: () => Promise<T>, remember: (result: T) => void): Promise<void> {
state.pending = phase
await save()
try {
const result = await action()
remember(result)
delete state.pending
await save()
} catch (error) {
const status = statusOf(error)
// A definitive client-error response refused this operation. A lost response stays uncertain.
if (status !== undefined && status >= 400 && status < 500) {
delete state.pending
await save()
}
throw new RecipeError(`${phase} failed. Keep ${statePath}; inspect it before recovery.`, { cause: error })
}
}
function bucketInput() {
return { Bucket: state.bucket, ExpectedBucketOwner: account }
}
function policy() {
return {
Version: '2012-10-17', Statement: [{ Effect: 'Allow', Principal: { Service: 'cloudfront.amazonaws.com' },
Action: 's3:GetObject', Resource: `arn:aws:s3:::${state.bucket}/images/*`,
Condition: { StringEquals: { 'AWS:SourceArn': state.distributionArn } } }],
}
}
async function unversioned(): Promise<void> {
const result = await s3.send(new GetBucketVersioningCommand(bucketInput()), options())
check(result.Status === undefined, 'Versioning changed. Stop and inventory versions before deleting anything.')
}
async function deployed(enabled: boolean) {
check(state.distributionId, 'No confirmed distribution ID in the state file.')
const deadline = Date.now() + 1_200_000
for (let observation = 0; observation < 21 && Date.now() < deadline; observation += 1) {
const result = await cf.send(new GetDistributionCommand({ Id: state.distributionId }), {
abortSignal: AbortSignal.timeout(Math.min(6_000, Math.max(1, deadline - Date.now()))),
})
const config = result.Distribution?.DistributionConfig
check(config?.CallerReference === state.owner, 'Distribution ownership mismatch.')
check(config.Enabled === enabled, 'Distribution enabled state differs from the requested state.')
if (result.Distribution?.Status === 'Deployed') return result
check(result.Distribution?.Status === 'InProgress', 'Unexpected distribution deployment status.')
await delay(Math.min(60_000, Math.max(1, deadline - Date.now())))
}
throw new RecipeError('Deployment wait expired. Outcome is incomplete; retain the state and run destroy.')
}
async function verify(): Promise<void> {
check(state.domain && state.distributionArn && state.oacId, 'Deployment is incomplete.')
const distribution = await deployed(true)
const config = distribution.Distribution?.DistributionConfig
check(config, 'Distribution configuration is missing.')
const origins = config.Origins
check(origins, 'Distribution origins are missing.')
const origin = origins.Items?.[0]
check(origins.Quantity === 1 && origin?.DomainName === `${state.bucket}.s3.${region}.amazonaws.com`
&& origin.OriginAccessControlId === state.oacId && origin.S3OriginConfig?.OriginAccessIdentity === '',
'Expected a regular S3 origin using this OAC.')
const behavior = config.DefaultCacheBehavior
check(behavior, 'Default cache behavior is missing.')
check(behavior.CachePolicyId === cachePolicyId && behavior.ViewerProtocolPolicy === 'https-only'
&& behavior.TrustedKeyGroups?.Enabled !== true && behavior.TrustedSigners?.Enabled !== true,
'Expected public HTTPS viewers and the selected cache policy.')
const access = await s3.send(new GetPublicAccessBlockCommand(bucketInput()), options())
const block = access.PublicAccessBlockConfiguration
check(block?.BlockPublicAcls && block.IgnorePublicAcls && block.BlockPublicPolicy && block.RestrictPublicBuckets,
'All four S3 Block Public Access settings must stay enabled.')
const ownership = await s3.send(new GetBucketOwnershipControlsCommand(bucketInput()), options())
check(ownership.OwnershipControls?.Rules?.[0]?.ObjectOwnership === 'BucketOwnerEnforced', 'S3 ACLs must stay disabled.')
const storedPolicy = await s3.send(new GetBucketPolicyCommand(bucketInput()), options())
assert.deepStrictEqual(JSON.parse(storedPolicy.Policy ?? 'null'), policy())
const oac = await cf.send(new GetOriginAccessControlCommand({ Id: state.oacId }), options())
const signing = oac.OriginAccessControl?.OriginAccessControlConfig
check(signing?.Name === state.bucket && signing.SigningBehavior === 'always'
&& signing.SigningProtocol === 'sigv4' && signing.OriginAccessControlOriginType === 's3', 'OAC signing mismatch.')
const cache = await cf.send(new GetCachePolicyCommand({ Id: behavior.CachePolicyId }), options())
const cacheConfig = cache.CachePolicy?.CachePolicyConfig
check(cacheConfig?.MinTTL === 1 && cacheConfig.DefaultTTL === 86400 && cacheConfig.MaxTTL === 31536000,
'CloudFront cache TTLs differ from the declared policy.')
const readSignal = AbortSignal.timeout(6_000)
const stored = await s3.send(new GetObjectCommand({ ...bucketInput(), Key: key }), { abortSignal: readSignal })
check(stored.CacheControl === cacheControl && stored.ContentType === 'image/png' && stored.ContentLength === bytes.length,
'Uploaded image metadata differs from the declared cache policy or input.')
check(stored.Body instanceof Readable, 'Expected the Node.js S3 response stream.')
addAbortSignal(readSignal, stored.Body)
const storedBytes = await stored.Body.transformToByteArray()
check(storedBytes && Buffer.from(storedBytes).equals(bytes), 'Stored S3 bytes differ from the input.')
const url = `https://${state.domain}/${key}`
const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30_000) })
check(response.status === 200 && response.headers.get('cache-control') === cacheControl
&& response.headers.get('content-type') === 'image/png', 'CloudFront response status or metadata differs.')
const delivered = Buffer.from(await response.arrayBuffer())
check(delivered.length === bytes.length && delivered.equals(bytes)
&& createHash('sha256').update(delivered).digest('hex') === digest, 'CloudFront image bytes differ from the input.')
const originResponse = await fetch(`https://${state.bucket}.s3.${region}.amazonaws.com/${key}`, {
redirect: 'error', signal: AbortSignal.timeout(30_000),
})
await originResponse.body?.cancel()
check([401, 403, 404].includes(originResponse.status), 'Anonymous S3 origin access was not denied.')
console.log(JSON.stringify({ url, key, bytes: delivered.length, sha256: digest,
cacheControl, directOriginStatus: originResponse.status }))
}
async function publish(): Promise<void> {
await unversioned()
state.attemptedKey = key
await mutate('PutObject', () => s3.send(new PutObjectCommand({ ...bucketInput(), Key: key,
Body: bytes, ContentType: 'image/png', CacheControl: cacheControl, IfNoneMatch: '*',
ServerSideEncryption: 'AES256' }), options()), () => {
if (!state.keys.includes(key)) state.keys.push(key)
delete state.attemptedKey
})
await verify()
}
async function absent(action: () => Promise<unknown>): Promise<void> {
try {
await action()
} catch (error) {
if (statusOf(error) === 404) return
throw error
}
throw new RecipeError('A resource still exists after deletion. Retain the state file.')
}
async function destroy(): Promise<void> {
if (state.bucketCreated) {
await unversioned()
const initial = await s3.send(new ListObjectsV2Command(bucketInput()), options())
check(!initial.IsTruncated && (initial.Contents ?? []).every((object) => object.Key && state.keys.includes(object.Key)),
'Unexpected objects or pagination. Stop; do not delete unrecorded keys.')
}
if (state.distributionId) {
const current = await cf.send(new GetDistributionCommand({ Id: state.distributionId }), options())
const config = current.Distribution?.DistributionConfig
check(config?.CallerReference === state.owner && current.ETag, 'Distribution ownership or ETag mismatch.')
if (config.Enabled) {
await mutate('DisableDistribution', () => cf.send(new UpdateDistributionCommand({ Id: state.distributionId,
IfMatch: current.ETag, DistributionConfig: { ...config, Enabled: false } }), options()), () => {})
}
const disabled = await deployed(false)
check(disabled.ETag, 'Missing disabled distribution ETag.')
await mutate('DeleteDistribution', () => cf.send(new DeleteDistributionCommand({ Id: state.distributionId,
IfMatch: disabled.ETag }), options()), () => {})
await absent(() => cf.send(new GetDistributionCommand({ Id: state.distributionId }), options()))
delete state.distributionId
await save()
}
if (state.oacId) {
const current = await cf.send(new GetOriginAccessControlCommand({ Id: state.oacId }), options())
check(current.OriginAccessControl?.OriginAccessControlConfig?.Name === state.bucket && current.ETag,
'OAC ownership or ETag mismatch.')
await mutate('DeleteOriginAccessControl', () => cf.send(new DeleteOriginAccessControlCommand({ Id: state.oacId,
IfMatch: current.ETag }), options()), () => {})
await absent(() => cf.send(new GetOriginAccessControlCommand({ Id: state.oacId }), options()))
delete state.oacId
await save()
}
if (state.bucketCreated) {
await unversioned()
const listing = await s3.send(new ListObjectsV2Command(bucketInput()), options())
check(!listing.IsTruncated && (listing.Contents ?? []).every((object) => object.Key && state.keys.includes(object.Key)),
'Unexpected objects or pagination. Stop; do not delete unrecorded keys.')
for (const ownedKey of state.keys) {
await mutate('DeleteObject', () => s3.send(new DeleteObjectCommand({ ...bucketInput(), Key: ownedKey }), options()), () => {})
}
const empty = await s3.send(new ListObjectsV2Command(bucketInput()), options())
check(!empty.IsTruncated && (empty.Contents ?? []).length === 0, 'Bucket is not confirmed empty.')
await mutate('DeleteBucket', () => s3.send(new DeleteBucketCommand(bucketInput()), options()), () => {})
await absent(() => s3.send(new HeadBucketCommand(bucketInput()), options()))
state.bucketCreated = false
await save()
}
state.removed = true
await save()
console.log(JSON.stringify({ removed: true, bucket: state.bucket }))
}
try {
if (command === 'deploy') {
const owner = values.id ?? randomUUID()
check(uuidPattern.test(owner), '--id must be a lowercase UUID v4.')
state = { owner, bucket: `image-cdn-${owner}`, bucketCreated: false, keys: [] }
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
await mutate('CreateBucket', () => s3.send(new CreateBucketCommand({ Bucket: state.bucket }),
options()), () => { state.bucketCreated = true })
await unversioned()
await mutate('CreateOriginAccessControl', () => cf.send(new CreateOriginAccessControlCommand({
OriginAccessControlConfig: { Name: state.bucket, OriginAccessControlOriginType: 's3', SigningBehavior: 'always', SigningProtocol: 'sigv4' },
}), options()), (result) => {
check(result.OriginAccessControl?.Id, 'Missing OAC ID.')
state.oacId = result.OriginAccessControl.Id
})
await mutate('CreateDistribution', () => cf.send(new CreateDistributionCommand({ DistributionConfig: {
CallerReference: state.owner, Comment: state.bucket, Enabled: true,
Origins: { Quantity: 1, Items: [{ Id: 'images', DomainName: `${state.bucket}.s3.${region}.amazonaws.com`,
OriginAccessControlId: state.oacId, S3OriginConfig: { OriginAccessIdentity: '' } }] },
DefaultCacheBehavior: { TargetOriginId: 'images', ViewerProtocolPolicy: 'https-only', CachePolicyId: cachePolicyId,
Compress: false, AllowedMethods: { Quantity: 2, Items: ['GET', 'HEAD'], CachedMethods: { Quantity: 2, Items: ['GET', 'HEAD'] } },
TrustedSigners: { Enabled: false, Quantity: 0 }, TrustedKeyGroups: { Enabled: false, Quantity: 0 } },
ViewerCertificate: { CloudFrontDefaultCertificate: true },
} }), options()), (result) => {
const distribution = result.Distribution
check(distribution?.Id && distribution.ARN && distribution.DomainName, 'Missing distribution identifiers.')
state.distributionId = distribution.Id
state.distributionArn = distribution.ARN
state.domain = distribution.DomainName
})
check(state.distributionArn === `arn:aws:cloudfront::${account}:distribution/${state.distributionId}`, 'AWS account mismatch.')
await mutate('PutBucketPolicy', () => s3.send(new PutBucketPolicyCommand({ ...bucketInput(), Policy: JSON.stringify(policy()) }), options()), () => {})
await publish()
} else {
const saved: unknown = JSON.parse(await readFile(statePath, 'utf8'))
check(validState(saved), 'Invalid state file. Do not edit ownership records.')
state = saved
check(!state.distributionId || !state.distributionArn || state.distributionArn === `arn:aws:cloudfront::${account}:distribution/${state.distributionId}`,
'State belongs to another account, or the distribution ID was changed.')
check(!state.pending, `Uncertain operation: ${state.pending}. Inspect AWS before recovery; do not blindly retry.`)
if (command === 'destroy') await destroy()
else {
check(!state.removed, 'Resources were removed. Create a fresh project for another deployment.')
if (command === 'publish') await publish()
else await verify()
}
}
} finally {
s3.destroy()
cf.destroy()
}
}
main().catch((error: unknown) => {
console.error(error instanceof RecipeError ? error.message : 'AWS or local operation failed. Retain cdn-state.json and check access, input, and state.')
process.exitCode = 1
})
Testing your CDN
Run the following from the scratch directory, with the required environment variables already set:
(cd image-cdn && node cdn.ts deploy ./image.png)
The command creates a UUID-named bucket, OAC, and distribution, attaches a distribution-scoped bucket
policy, and uploads the image. For automation, deploy also accepts --id followed by a fresh
lowercase UUID v4. It refuses to replace an existing cdn-state.json.
Success prints one JSON record containing url, key, bytes, sha256, cacheControl, and
directOriginStatus. Open that exact url in a browser to display the image. Before printing it,
the program reads the stored object with credentials, compares the complete anonymous CloudFront
response with your local input, checks cache metadata and configuration, and confirms that an
anonymous request for the same known S3 object is denied. An HTTP 200 alone does not pass the check.
To repeat verification without uploading again:
(cd image-cdn && node cdn.ts verify ./image.png)
Optimizing image delivery
The object key contains the input’s SHA-256 digest. Different bytes get a different key, and
IfNoneMatch: '*' prevents replacement of an existing object. To publish a changed PNG, save it
as image-cdn/image-v2.png and run:
(cd image-cdn && node cdn.ts publish ./image-v2.png)
Use the new printed URL in your application. Publishing identical bytes again is an overwrite
refusal; use verify when you only want to recheck an existing image. Do not overwrite a cached
name or expect a changed query string to create a new image version.
CloudFront combines origin cache headers with the cache policy’s TTL bounds. A year-long
max-age permits that cache lifetime; it does not guarantee that an edge retains the object for a
year. AWS describes the interaction in its
cache expiration guide.
The example verifies the configuration and response headers without requiring a cache-hit header
or claiming a measured latency improvement.
Troubleshooting common issues
If deploy or publish exits unsuccessfully, retain cdn-state.json. A failed AWS mutation names
its phase. Definitive client-error responses leave confirmed earlier resources available for
destroy. A lost response leaves a pending operation because its remote outcome is uncertain;
the program refuses further work. Inspect that exact named resource and resolve the uncertain
operation before attempting recovery. Do not delete the state file and rerun deployment blindly.
For an ordinary verification failure, recheck the input and run verify again. It can distinguish
wrong CloudFront bytes, changed upload metadata, an unexpected cache policy, and anonymous S3
exposure. A timeout or an access failure remains a failed check; neither proves private-origin
denial. This example does not install CORS rules or support browser JavaScript fetching from a
different origin.
Remove the resources
Keep the same AWS environment and run:
(cd image-cdn && node cdn.ts destroy)
CloudFront requires a distribution to be disabled before deletion. The program preserves its
configuration and uses the returned ETags for updates and deletion, following the
DeleteDistribution API.
It waits for the disabled configuration to deploy, deletes the distribution and OAC, removes only
recorded image keys, checks that the bucket is empty and unversioned, and deletes it. It checks
resource absence before printing removed: true.
A failed teardown exits unsuccessfully and retains the state for inspection. Unexpected objects, versioning changes, and uncertain mutations require investigation rather than broader deletion. Stopping Node.js does not remove AWS resources. Keep the project and arrange cleanup if propagation exceeds the wait window; resource usage continues until you remove the resources.
Rather not run this yourself?
Transloadit’s Smart CDN transforms and delivers files on the fly.
