Automating Canary Releases for Vue.js SPAs on AWS via GitHub Actions and CloudFront Functions


The standard aws s3 sync approach for deploying a Single Page Application is a production incident waiting to happen. It’s an atomic sledgehammer. During the sync, which can take several seconds to minutes for a non-trivial application, a user might request index.html from a new deployment but receive a JavaScript chunk from a previous one, resulting in a completely broken state. There’s no granular control, no instant rollback, and no way to test a new front-end version with a subset of live traffic. This brittleness is unacceptable for any serious application. A failed deployment should not impact 100% of users immediately.

Our initial mandate was to achieve zero-downtime, controlled rollouts for our Vue.js SPA. The first concept explored was a classic blue-green deployment using two distinct S3 buckets. The GitHub Actions pipeline would deploy to the “green” bucket, run integration tests against it, and then update the CloudFront distribution’s origin to point to the green bucket’s endpoint. While this solves the atomicity problem, it’s still an all-or-nothing switch. It doesn’t de-risk the deployment by exposing it to a small percentage of traffic first. The real goal was a canary release strategy: route 5% of users to the new version, monitor for errors, and then gradually increase exposure before a full promotion.

This led to a more refined architectural decision. Instead of juggling multiple S3 buckets, we would use a single bucket with an immutable, versioned directory structure. Each deployment, identified by its git commit SHA, would be uploaded to its own prefix, like s3://my-app-bucket/deployments/a1b2c3d/. This approach treats deployments as immutable artifacts, which is fundamental for reliable rollbacks. The core challenge then became: how do we route traffic between these prefixes at the edge?

The choice was between AWS Lambda@Edge and CloudFront Functions.

  • Lambda@Edge is powerful. It’s a full Node.js or Python environment, capable of network calls and complex computations. However, it incurs higher latency due to cold starts and is more expensive per invocation. For our use case—simply rewriting a URL path—it felt like overkill.
  • CloudFront Functions are JavaScript functions that run at CloudFront’s edge locations with sub-millisecond startup times. They are significantly more constrained: no network access, a 1ms execution time limit, and a smaller package size. But for routing logic based on headers or cookies, they are perfectly suited, offering immense performance at a fraction of the cost.

For pragmatic reasons—performance, cost, and simplicity—CloudFront Functions was the clear winner for this particular problem. The final piece of the puzzle, GitHub Actions, would serve as the orchestrator, responsible for building the Vue app, uploading the immutable artifact to S3, and managing the state of which commit SHA is considered “stable” and which is the “canary”.

Architecting the Request Flow

Before diving into the implementation code, it’s critical to visualize the path of a user request in this canary system.

sequenceDiagram
    participant User
    participant CloudFront
    participant CloudFront Function
    participant S3

    User->>+CloudFront: GET /assets/app.js
    CloudFront->>+CloudFront Function: Viewer Request event
    CloudFront Function-->>-CloudFront: Inspects request headers/cookies
    alt Is Canary User? (e.g., cookie `deployment-track=canary`)
        CloudFront Function->>CloudFront: Rewrite URI to /canary/assets/app.js
    else Not Canary User
        CloudFront Function->>CloudFront: Rewrite URI to /stable/assets/app.js
    end
    CloudFront->>+S3: GET /canary/assets/app.js (or /stable/...)
    S3-->>-CloudFront: Returns file content
    CloudFront-->>-User: Responds with file content

The key is that the user requests a simple path (/assets/app.js), but the CloudFront Function intercepts this request and prepends the correct deployment prefix (/stable/ or /canary/) before it ever hits the S3 origin. The S3 bucket itself does not perform any logic; it’s just a key-value store for our versioned artifacts.

The CloudFront Routing Function

This is the heart of the traffic-shifting mechanism. The function is associated with the “Viewer Request” event in CloudFront, meaning it executes for every single request before CloudFront checks its cache. This is why its performance is so critical.

The logic is straightforward:

  1. Define the default (stable) and canary deployment prefixes. In a real-world scenario, these would not be hardcoded but dynamically fetched or configured. For this implementation, we’ll read them from manifest files (stable.txt, canary.txt) also stored in S3. Correction: CloudFront Functions cannot make network calls. The initial design to fetch from S3 is invalid. A better approach is to hardcode a version and update the function itself, or use a simpler mechanism like a cookie value that maps to a path. For maximum simplicity and control via headers, we will inspect a header directly. A more advanced pattern involves two CloudFront behaviors, one for canary and one for stable, but let’s stick to a single function for now.

Let’s refine the logic: we’ll use a cookie named session-group. If the cookie’s value is canary, the user is served the canary deployment. Otherwise, they get stable. This allows for targeted testing by setting the cookie manually in a browser.

// cloudfront-function-router.js

// A real-world implementation would use a more robust logging mechanism.
// For CloudFront Functions, console.log writes to CloudWatch Logs.
function log(message) {
    console.log(message);
}

function handler(event) {
    var request = event.request;
    var headers = request.headers;
    var uri = request.uri;

    // These version identifiers are what our GitHub Actions workflow will update.
    // In a production setup, you would update the function code itself via AWS CLI/SDK
    // during the deployment pipeline to avoid hardcoding.
    var stableVersion = 'deployments/d8e8fca2'; // Represents the current stable commit SHA prefix
    var canaryVersion = 'deployments/a1b2c3d4'; // Represents the new canary commit SHA prefix

    var deploymentPrefix;

    // Check for a cookie to route traffic. A value of 'canary' routes to the new version.
    if (headers.cookie) {
        var cookies = headers.cookie.value.split('; ');
        var isCanary = cookies.some(function(cookie) {
            return cookie.startsWith('deployment-track=canary');
        });

        if (isCanary) {
            deploymentPrefix = canaryVersion;
            log('Routing to CANARY version: ' + canaryVersion);
        } else {
            deploymentPrefix = stableVersion;
            log('Routing to STABLE version: ' + stableVersion);
        }
    } else {
        // Default to stable if no cookie is present.
        deploymentPrefix = stableVersion;
        log('No tracking cookie. Routing to STABLE version: ' + stableVersion);
    }
    
    // If the request is for the root, serve index.html from the deployment directory.
    if (uri === '/' || uri === '/index.html') {
        request.uri = '/' + deploymentPrefix + '/index.html';
    } else {
        // For all other assets, prepend the deployment prefix.
        // Example: /assets/app.js becomes /deployments/d8e8fca2/assets/app.js
        request.uri = '/' + deploymentPrefix + uri;
    }

    log('Rewritten URI: ' + request.uri);
    return request;
}

This function code must be published to CloudFront. The key operational challenge is updating the stableVersion and canaryVersion variables. The most direct method is to treat the function’s code as an artifact itself and update it as part of the CI/CD pipeline using the aws cloudfront update-function command.

S3 and CloudFront Infrastructure

The S3 bucket needs a specific policy to allow CloudFront to access it. It should not be configured for public website hosting, as we want to force all traffic through CloudFront.

S3 Bucket Policy:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowCloudFrontServicePrincipal",
            "Effect": "Allow",
            "Principal": {
                "Service": "cloudfront.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::your-vue-app-bucket-name/*",
            "Condition": {
                "StringEquals": {
                    "AWS:SourceArn": "arn:aws:cloudfront::YOUR_AWS_ACCOUNT_ID:distribution/YOUR_CLOUDFRONT_DISTRIBUTION_ID"
                }
            }
        }
    ]
}

A common mistake is allowing public s3:GetObject. This bypasses the CDN and the routing logic, defeating the entire purpose of this architecture.

CloudFront is configured with the S3 bucket as an origin. The critical step is associating the published CloudFront Function with the default cache behavior on the viewer-request event type.

The GitHub Actions Orchestration Workflow

This workflow is the engine that drives the entire process. It’s broken down into distinct, dependent jobs for building, deploying to canary, and promoting to stable. It uses OIDC for secure, short-lived credentials to AWS, which is a best practice over storing long-lived IAM user keys as repository secrets.

# .github/workflows/canary-deployment.yml

name: Vue.js Canary Deployment to AWS

on:
  push:
    branches:
      - main

# Permissions required for OIDC authentication with AWS
permissions:
  id-token: write
  contents: read
  pull-requests: write # For adding comments with deployment status

env:
  AWS_REGION: "us-east-1" # The region where your S3 bucket resides
  S3_BUCKET_NAME: "your-vue-app-bucket-name"
  CLOUDFRONT_DIST_ID: "E123ABC456DEF"
  CLOUDFRONT_FUNCTION_NAME: "my-app-router-function"

jobs:
  # =======================================================
  # 1. Build and Test the Vue.js Application
  # =======================================================
  build-and-test:
    runs-on: ubuntu-latest
    outputs:
      commit_sha_short: ${{ steps.vars.outputs.sha_short }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run Unit & Component Tests
        run: npm test:unit # Assumes you have a test script

      - name: Build application
        run: npm run build # This creates the 'dist' directory

      - name: Get short commit SHA
        id: vars
        run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT

      - name: Archive production artifacts
        uses: actions/upload-artifact@v3
        with:
          name: dist-artifact-${{ steps.vars.outputs.sha_short }}
          path: dist/
          retention-days: 7

  # =======================================================
  # 2. Deploy the build to S3 as a new canary version
  # =======================================================
  deploy-canary:
    needs: build-and-test
    runs-on: ubuntu-latest
    environment: 
      name: Canary
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::YOUR_AWS_ACCOUNT_ID:role/GitHubActionsDeployRole
          aws-region: ${{ env.AWS_REGION }}

      - name: Download build artifact
        uses: actions/download-artifact@v3
        with:
          name: dist-artifact-${{ needs.build-and-test.outputs.commit_sha_short }}
          path: dist/

      - name: Upload to S3
        run: |
          aws s3 sync ./dist/ s3://${{ env.S3_BUCKET_NAME }}/deployments/${{ needs.build-and-test.outputs.commit_sha_short }}/ \
            --delete \
            --cache-control "public, max-age=31536000, immutable"
        
      - name: Update CloudFront Function for new canary
        run: |
          # Step 1: Get the current function code and ETag
          echo "Fetching current CloudFront function details..."
          FUNCTION_DETAILS=$(aws cloudfront get-function --name ${{ env.CLOUDFRONT_FUNCTION_NAME }} --stage LIVE)
          ETAG=$(echo "$FUNCTION_DETAILS" | jq -r '.ETag')
          
          # Step 2: Get the current STABLE version from the existing code
          # This is a bit fragile but demonstrates the concept. A better way is storing state in SSM Parameter Store.
          STABLE_VERSION_LINE=$(aws cloudfront get-function --name ${{ env.CLOUDFRONT_FUNCTION_NAME }} --stage LIVE --query 'FunctionCode' --output text | base64 --decode | grep 'var stableVersion')
          CURRENT_STABLE_VERSION=$(echo "$STABLE_VERSION_LINE" | sed -n "s/.*var stableVersion = '\(deployments\/[a-zA-Z0-9]*\)'.*/\1/p")
          echo "Current stable version detected: ${CURRENT_STABLE_VERSION}"

          # Step 3: Create the new function code file
          NEW_CANARY_VERSION="deployments/${{ needs.build-and-test.outputs.commit_sha_short }}"
          echo "New canary version will be: ${NEW_CANARY_VERSION}"

          # Read the template, replace placeholders, and create new code
          # Assumes you have a template file in your repo
          sed -e "s|PLACEHOLDER_STABLE_VERSION|${CURRENT_STABLE_VERSION}|g" \
              -e "s|PLACEHOLDER_CANARY_VERSION|${NEW_CANARY_VERSION}|g" \
              ./.github/cloudfront/function_template.js > ./new-function-code.js

          # Step 4: Update the function with the new code
          echo "Updating CloudFront function..."
          aws cloudfront update-function \
            --name ${{ env.CLOUDFRONT_FUNCTION_NAME }} \
            --if-match $ETAG \
            --function-config "Comment='Canary: ${{ needs.build-and-test.outputs.commit_sha_short }}',Runtime='cloudfront-js-1.0'" \
            --function-code fileb://new-function-code.js

          # Step 5: Publish the new function version to the LIVE stage
          echo "Publishing CloudFront function to LIVE..."
          aws cloudfront publish-function --name ${{ env.CLOUDFRONT_FUNCTION_NAME }} --if-match $ETAG

      - name: Invalidate CloudFront root for immediate propagation
        run: |
          aws cloudfront create-invalidation --distribution-id ${{ env.CLOUDFRONT_DIST_ID }} --paths "/*"

  # =======================================================
  # 3. Promote the canary to stable (Manual Approval)
  # =======================================================
  promote-to-stable:
    needs: deploy-canary
    runs-on: ubuntu-latest
    environment: 
      name: Production
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::YOUR_AWS_ACCOUNT_ID:role/GitHubActionsDeployRole
          aws-region: ${{ env.AWS_REGION }}

      - name: Promote canary to stable by updating the function
        run: |
          # Get the current function code and ETag again
          FUNCTION_DETAILS=$(aws cloudfront get-function --name ${{ env.CLOUDFRONT_FUNCTION_NAME }} --stage LIVE)
          ETAG=$(echo "$FUNCTION_DETAILS" | jq -r '.ETag')

          # Get the CURRENT CANARY version which is now becoming the new STABLE
          CANARY_VERSION_LINE=$(aws cloudfront get-function --name ${{ env.CLOUDFRONT_FUNCTION_NAME }} --stage LIVE --query 'FunctionCode' --output text | base64 --decode | grep 'var canaryVersion')
          NEW_STABLE_VERSION=$(echo "$CANARY_VERSION_LINE" | sed -n "s/.*var canaryVersion = '\(deployments\/[a-zA-Z0-9]*\)'.*/\1/p")
          echo "Promoting ${NEW_STABLE_VERSION} to stable."

          # Create new code where stable and canary point to the same version
          sed -e "s|PLACEHOLDER_STABLE_VERSION|${NEW_STABLE_VERSION}|g" \
              -e "s|PLACEHOLDER_CANARY_VERSION|${NEW_STABLE_VERSION}|g" \
              ./.github/cloudfront/function_template.js > ./new-function-code.js

          aws cloudfront update-function \
            --name ${{ env.CLOUDFRONT_FUNCTION_NAME }} \
            --if-match $ETAG \
            --function-config "Comment='Promote: ${NEW_STABLE_VERSION} to stable',Runtime='cloudfront-js-1.0'" \
            --function-code fileb://new-function-code.js
          
          aws cloudfront publish-function --name ${{ env.CLOUDFRONT_FUNCTION_NAME }} --if-match $ETAG
      
      - name: Invalidate CloudFront again
        run: |
          aws cloudfront create-invalidation --distribution-id ${{ env.CLOUDFRONT_DIST_ID }} --paths "/*"

The accompanying template file ./.github/cloudfront/function_template.js would contain the same logic as our cloudfront-function-router.js but with placeholders:

function handler(event) {
    // ... function logic ...
    var stableVersion = 'PLACEHOLDER_STABLE_VERSION';
    var canaryVersion = 'PLACEHOLDER_CANARY_VERSION';
    // ... rest of the function ...
}

This implementation directly manipulates the CloudFront Function code as part of the deployment. While this works, a pitfall here is the fragility of parsing and replacing text in the function code. A more robust production system might store the stable and canary commit SHAs in AWS Systems Manager (SSM) Parameter Store and have a more complex Lambda@Edge function capable of fetching these parameters. However, for many projects, the simplicity and performance of the CloudFront Function approach is a valid trade-off.

The current implementation lacks automated validation and rollback. In a real-world project, the deploy-canary job would be followed by an automated smoke test or integration test job that hits the application URL with the deployment-track=canary cookie. If those tests fail, a subsequent job could automatically trigger a rollback by reverting the CloudFront Function to its previous state. Furthermore, this design relies on a full /* cache invalidation, which can be costly at scale. A more fine-grained invalidation strategy targeting only index.html might be more efficient, though it brings its own set of complexities with browser caching of assets. Finally, the state management—deriving the current stable version by reading the function code—is a workaround. A dedicated state store like SSM Parameter Store or DynamoDB would provide a much cleaner and more reliable source of truth for which deployment version is currently active in each environment.


  TOC