AWS CloudFront with S3
Amazon S3 stores your Messagevisor datafiles and CloudFront distributes them from edge locations worldwide. This pattern integrates naturally into AWS-based infrastructure: your CI pipeline writes to S3 and CloudFront handles global delivery, caching, and HTTPS termination with no servers to operate.
GitHub Actions builds the datafiles and syncs them to S3 on every merge to main. Pull requests only validate - nothing reaches S3 until a change is approved and merged.
How it works#
┌─────────────────────────────────┐│ Messagevisor repository ││ YAML definitions + tests │└───────────────┬─────────────────┘ │ push to main ▼┌─────────────────────────────────┐│ GitHub Actions ││ lint → test → build → sync │└───────────────┬─────────────────┘ │ aws s3 sync ▼┌─────────────────────────────────┐│ Amazon S3 ││ s3://your-bucket/datafiles/ ││ messagevisor-web-en-US.json │└───────────────┬─────────────────┘ │ origin request ▼┌─────────────────────────────────┐│ Amazon CloudFront ││ global edge (300+ PoPs) │└───────────────┬─────────────────┘ │ fetch on startup + poll ▼┌─────────────────────────────────┐│ Your application │└─────────────────────────────────┘Prerequisites#
- An AWS account
- An S3 bucket for datafiles
- A CloudFront distribution pointing at the S3 bucket
- AWS credentials with
s3:PutObject,s3:DeleteObject,cloudfront:CreateInvalidationpermissions awsCLI available (pre-installed on GitHub Actionsubuntu-latestrunners)
S3 bucket configuration#
Create an S3 bucket to hold your datafiles. The bucket does not need to be publicly accessible - CloudFront will read from it using an Origin Access Control (OAC) policy.
Recommended bucket settings:
- Block all public access: enabled
- Versioning: optional (Git is your version history)
- Server-side encryption: enabled (SSE-S3 or SSE-KMS)
Origin Access Control#
To let CloudFront read from a private bucket, attach an OAC policy. In the AWS console:
- Go to CloudFront → Origin access → Create control setting
- Set "Signing behavior" to "Sign requests (recommended)"
- Attach the OAC to your CloudFront distribution's S3 origin
Then add this bucket policy (replace the placeholder values):
{ "Version": "2012-10-17", "Statement": [ { "Sid": "AllowCloudFrontServicePrincipal", "Effect": "Allow", "Principal": { "Service": "cloudfront.amazonaws.com" }, "Action": "s3:GetObject", "Resource": "arn:aws:s3:::your-bucket-name/*", "Condition": { "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::your-account-id:distribution/your-distribution-id" } } } ]}CloudFront distribution configuration#
Configure the distribution to serve datafiles from S3:
| Setting | Value |
|---|---|
| Origin domain | your-bucket-name.s3.your-region.amazonaws.com |
| Origin access | Origin access control (OAC) |
| Viewer protocol policy | Redirect HTTP to HTTPS |
| Allowed HTTP methods | GET, HEAD, OPTIONS |
| Cache policy | Managed-CachingOptimized (or custom, see below) |
| Response headers policy | Create a custom policy with CORS headers |
Cache policy#
The managed CachingOptimized policy caches based on Cache-Control headers from S3. Configure S3 object metadata (or use the sync command below) to set Cache-Control: public, max-age=300 so CloudFront caches each file for 5 minutes.
CORS response headers policy#
Create a response headers policy in CloudFront → Policies → Response headers:
Access-Control-Allow-Origin:*(or restrict to your app domains)Access-Control-Allow-Methods:GET, OPTIONSAccess-Control-Max-Age:86400
GitHub Actions workflow#
The workflow has two jobs: validate runs on every pull request and push, deploy runs only on merges to main.
name: Messagevisoron: pull_request: push: branches: [main]jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx messagevisor lint - run: npx messagevisor test - run: npx messagevisor build --showSize deploy: runs-on: ubuntu-latest needs: validate if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx messagevisor build - name: Sync datafiles to S3 run: | aws s3 sync datafiles/ s3://${{ secrets.AWS_S3_BUCKET }}/datafiles/ \ --cache-control "public, max-age=300" \ --content-type "application/json" \ --delete env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} - name: Invalidate CloudFront cache run: | aws cloudfront create-invalidation \ --distribution-id ${{ secrets.CLOUDFLARE_DISTRIBUTION_ID }} \ --paths "/datafiles/*" env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}Set these repository secrets in GitHub → Settings → Secrets and variables → Actions:
| Secret | Description |
|---|---|
AWS_ACCESS_KEY_ID | IAM user access key |
AWS_SECRET_ACCESS_KEY | IAM user secret key |
AWS_REGION | S3 bucket region (e.g. us-east-1) |
AWS_S3_BUCKET | Bucket name (without s3://) |
CLOUDFRONT_DISTRIBUTION_ID | CloudFront distribution ID (starts with E) |
Using OIDC instead of long-lived credentials#
For better security, use GitHub's OIDC provider to assume an IAM role directly - no long-lived access keys needed:
deploy: runs-on: ubuntu-latest needs: validate if: github.ref == 'refs/heads/main' permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/messagevisor-deploy aws-region: ${{ secrets.AWS_REGION }} - run: npm ci - run: npx messagevisor build - name: Sync datafiles to S3 run: | aws s3 sync datafiles/ s3://${{ secrets.AWS_S3_BUCKET }}/datafiles/ \ --cache-control "public, max-age=300" \ --content-type "application/json" \ --delete - name: Invalidate CloudFront cache run: | aws cloudfront create-invalidation \ --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \ --paths "/datafiles/*"Create an IAM role messagevisor-deploy with a trust policy that allows GitHub Actions to assume it via OIDC, and attach a policy granting s3:PutObject, s3:DeleteObject, and cloudfront:CreateInvalidation.
Sets-based deployment#
For projects using sets, build only the set being promoted and sync it to S3:
deploy: runs-on: ubuntu-latest needs: validate if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx messagevisor build --set=production - name: Sync production datafiles to S3 run: | aws s3 sync datafiles/ s3://${{ secrets.AWS_S3_BUCKET }}/datafiles/ \ --cache-control "public, max-age=300" \ --content-type "application/json" \ --delete env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} - name: Invalidate CloudFront cache run: | aws cloudfront create-invalidation \ --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \ --paths "/datafiles/*" env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}If you have multiple environments (staging, production), use separate S3 buckets and separate CloudFront distributions, with separate sets of secrets per environment.
Fetching datafiles at runtime#
Once deployed, datafiles are available at your CloudFront domain. Fetch them from your application at startup:
import { createMessagevisor } from "@messagevisor/sdk";const datafile = await fetch( "https://d1234abcd.cloudfront.net/datafiles/messagevisor-web-en-US.json").then((res) => res.json());const m = createMessagevisor({ datafile, locale: "en-US", context: { platform: "web" },});Or with a custom domain mapped to the CloudFront distribution:
const datafile = await fetch( "https://translations.yoursite.com/datafiles/messagevisor-web-en-US.json").then((res) => res.json());Custom domain#
To serve from a subdomain, add an alternate domain name (CNAME) to your CloudFront distribution and provision an ACM certificate:
- Request a certificate in AWS Certificate Manager (ACM) for
translations.yoursite.comin theus-east-1region (CloudFront requires certificates in us-east-1 regardless of your distribution's region) - Add
translations.yoursite.comas an alternate domain name in your CloudFront distribution settings - Associate the ACM certificate with the distribution
- Create a CNAME record in your DNS pointing
translations.yoursite.comto the CloudFront distribution domain (e.g.d1234abcd.cloudfront.net)
Cache invalidation#
The aws cloudfront create-invalidation step in the workflow sends invalidation requests for all datafiles immediately after the S3 sync completes. This means updated datafiles are globally available within seconds of a deploy, not after the cache TTL expires.
For cost-sensitive projects (each invalidation path counts against your free monthly quota), you can skip the invalidation step and rely on the Cache-Control: max-age=300 TTL instead. Updates will reach all edge locations within 5 minutes of the first request to each location after the deploy.
Revision file#
Write a revision file to S3 alongside the datafiles so applications can poll for changes efficiently:
- name: Write revision file run: | echo -n "${{ github.sha }}" > /tmp/REVISION aws s3 cp /tmp/REVISION s3://${{ secrets.AWS_S3_BUCKET }}/datafiles/REVISION \ --cache-control "no-store" \ --content-type "text/plain" env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}Your application polls the revision endpoint and only fetches a new full datafile when the revision changes:
let knownRevision = null;async function pollForUpdates(m) { const revision = await fetch( "https://translations.yoursite.com/datafiles/REVISION" ).then((res) => res.text()); if (revision !== knownRevision) { const fresh = await fetch( "https://translations.yoursite.com/datafiles/messagevisor-web-en-US.json" ).then((res) => res.json()); m.setDatafile(fresh, true); knownRevision = revision; }}// Poll every 5 minutessetInterval(() => pollForUpdates(m), 5 * 60 * 1000);IAM permissions#
The minimal IAM policy for the deployment user or role:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "WriteDatafiles", "Effect": "Allow", "Action": [ "s3:PutObject", "s3:DeleteObject", "s3:ListBucket" ], "Resource": [ "arn:aws:s3:::your-bucket-name", "arn:aws:s3:::your-bucket-name/*" ] }, { "Sid": "InvalidateCache", "Effect": "Allow", "Action": "cloudfront:CreateInvalidation", "Resource": "arn:aws:cloudfront::your-account-id:distribution/your-distribution-id" } ]}Scope the S3 resource to your specific bucket and the CloudFront resource to your specific distribution to follow the principle of least privilege.

