Constructing a Validated Immutable Artifact Pipeline for an Astro Application Using Packer and Cypress


The mandate was clear: deploy a content-rich Astro application with a non-negotiable requirement for deployment atomicity and environmental consistency. The standard push-to-deploy workflows offered by various PaaS providers were ruled out due to stringent internal network policies and the need for absolute control over the execution environment. This led us down the path of immutable infrastructure, where every release is a completely new, self-contained machine image. The initial concept seemed straightforward: use HashiCorp Packer to bake the static output of our Astro build into an Amazon Machine Image (AMI). In a real-world project, however, “straightforward” concepts are often where the most dangerous pitfalls are hidden. A simple baked image provides no guarantee of correctness. The application could build successfully but fail at runtime due to a misconfigured GraphQL endpoint or a subtle regression in the frontend logic. The true challenge became embedding a high-fidelity validation gate within the image creation process itself.

Our technology stack was set: Astro for the frontend build, a lightweight GraphQL client (urql) for data fetching, and Packer for image creation. The missing piece was the validation engine. For this, Cypress was the obvious, yet complicated, choice. Its end-to-end testing capabilities were precisely what we needed to verify user journeys and component interactions against a live, running server. The central problem was orchestrating these tools to work in concert within the ephemeral environment of a Packer build runner. We had to build, serve, and test the application inside a temporary machine that would cease to exist the moment the final artifact was produced.

The initial Packer configuration was rudimentary, focused solely on getting the application code onto the image. This is a common starting point, but it’s fundamentally flawed as it defers all validation to a post-deployment step, defeating the purpose of a pre-validated immutable artifact.

// packer.pkr.hcl - Stage 1: Naive Build-and-Bake
packer {
  required_plugins {
    amazon = {
      version = ">= 1.2.0"
      source  = "github.com/hashicorp/amazon"
    }
  }
}

variable "aws_region" {
  type    = string
  default = "us-east-1"
}

variable "app_version" {
  type    = string
  default = "0.1.0-dev"
}

source "amazon-ebs" "astro-app" {
  ami_name      = "astro-app-${var.app_version}-${timestamp()}"
  instance_type = "t3.micro"
  region        = var.aws_region
  source_ami_filter {
    filters = {
      name                = "ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    most_recent = true
    owners      = ["099720109477"] // Canonical's owner ID
  }
  ssh_username = "ubuntu"
}

build {
  name    = "astro-app-build"
  sources = ["source.amazon-ebs.astro-app"]

  provisioner "shell" {
    inline = [
      "echo 'Waiting for cloud-init to complete...'",
      "cloud-init status --wait",
      "sudo apt-get update -y",
      "sudo apt-get install -y curl nginx",
      "curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -",
      "sudo apt-get install -y nodejs"
    ]
  }

  provisioner "file" {
    source      = "../app/" // Assumes app source is one level up
    destination = "/tmp/app"
  }

  provisioner "shell" {
    inline = [
      "cd /tmp/app",
      "npm install",
      "npm run build",
      "sudo cp -R /tmp/app/dist/* /var/www/html/",
      "sudo rm -rf /tmp/app"
    ]
  }
}

This configuration successfully creates an AMI with the static assets served by Nginx. But it operates on blind faith. It assumes npm run build is a sufficient guarantee of quality. A common mistake is to conflate a successful build with a correct application. A build can succeed even if an environment variable pointing to the GraphQL API is missing, leading to an application that serves a shell but is functionally dead on arrival.

The critical evolution was to integrate a full end-to-end test suite execution into the Packer provisioning process. This meant the Packer builder instance had to not only build the Astro site but also serve it locally and run a headless browser to interact with it. This introduced significant complexity.

Our first hurdle was serving the application. The static files generated by npm run build aren’t enough for a comprehensive test; we need the Astro development server or a production-like preview server to handle server-side rendering (if any) and client-side navigation correctly. We opted for Astro’s preview command.

The second, more significant hurdle was Cypress. Cypress is not a lightweight dependency. It requires a browser binary and various system libraries for rendering. Installing these within the minimal Ubuntu server environment used by Packer requires careful dependency management.

Here is the architecture we settled on, visualized as a sequence diagram within the Packer build process:

sequenceDiagram
    participant Packer
    participant Builder_Instance as Builder Instance (EC2)
    participant Shell_Provisioner as Shell Provisioner
    participant Astro_App as Astro Preview Server
    participant Cypress

    Packer->>Builder_Instance: Launch from base AMI
    Builder_Instance->>Shell_Provisioner: Execute setup script
    Shell_Provisioner->>Builder_Instance: Install Node.js, Git, Nginx, Xvfb
    Shell_Provisioner->>Builder_Instance: Clone Application Repository
    Shell_Provisioner->>Builder_Instance: Run 'npm install' (installs Astro, Cypress, etc.)
    Shell_Provisioner->>Builder_Instance: Run 'npm run build'
    Shell_Provisioner->>Astro_App: Start 'npm run preview' in background on port 4321
    Note over Shell_Provisioner, Cypress: Wait for server to be ready
    Shell_Provisioner->>Cypress: Execute 'cypress run' against http://localhost:4321
    Cypress->>Astro_App: Interact with application, run assertions
    Astro_App-->>Cypress: Respond with HTML/JS/Data
    Cypress-->>Shell_Provisioner: Exit with code 0 (Success) or >0 (Failure)
    alt Tests Pass
        Shell_Provisioner->>Astro_App: Terminate preview server process
        Shell_Provisioner->>Builder_Instance: Configure production server (Nginx)
        Shell_Provisioner-->>Packer: Provisioning successful
        Packer->>Builder_Instance: Create AMI from instance
    else Tests Fail
        Shell_Provisioner-->>Packer: Provisioning failed (exit code > 0)
        Packer->>Builder_Instance: Terminate instance without creating AMI
        Packer->>Packer: Report build failure
    end

This refined process makes the test suite a mandatory quality gate for the artifact itself. If a single Cypress test fails, no AMI is produced. This prevents a defective build from ever reaching a deployment environment.

To implement this, we broke down the provisioning logic into modular shell scripts. Relying on long inline arrays in Packer’s HCL is unmaintainable for anything beyond trivial tasks.

The core of the logic resides in a single provision.sh script, which orchestrates the entire process.

#!/bin/bash
# scripts/provision.sh

# Exit immediately if a command exits with a non-zero status.
set -e

# --- Configuration ---
APP_DIR="/opt/astro-app"
BUILD_DIR="/tmp/build"
NODE_VERSION="18.x"
LOG_FILE="/var/log/packer-provision.log"

# --- Logging ---
# Redirect all output to a log file for easier debugging
exec > >(tee -a ${LOG_FILE}) 2>&1
echo "--- Starting Provisioning at $(date) ---"

# --- Helper Functions ---
function install_system_deps() {
    echo "--> Updating package list and installing system dependencies"
    # cloud-init can hold locks on apt, we must wait for it.
    cloud-init status --wait
    export DEBIAN_FRONTEND=noninteractive
    apt-get update -y
    apt-get install -y curl git nginx unzip libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb
    echo "--> System dependencies installed."
}

function install_node() {
    echo "--> Installing Node.js v${NODE_VERSION}"
    curl -fsSL "https://deb.nodesource.com/setup_${NODE_VERSION}" | bash -
    apt-get install -y nodejs
    echo "--> Node.js installed: $(node -v), npm: $(npm -v)"
}

function setup_app() {
    echo "--> Cloning application source"
    mkdir -p ${BUILD_DIR}
    # In a real CI pipeline, you would clone a specific commit hash
    git clone https://github.com/your-org/your-astro-app.git ${BUILD_DIR}
    cd ${BUILD_DIR}

    echo "--> Injecting GraphQL API Endpoint for build"
    # This is a critical step. The artifact must know its API endpoint.
    # We pass this via Packer variables.
    echo "PUBLIC_GRAPHQL_ENDPOINT=${GRAPHQL_ENDPOINT}" > .env

    echo "--> Installing application dependencies"
    # Using --frozen-lockfile for reproducible builds
    npm ci

    echo "--> Building the Astro application"
    npm run build
}

function run_validation_tests() {
    echo "--> Starting validation phase"
    cd ${BUILD_DIR}

    echo "--> Starting Astro preview server in background"
    # We use nohup to ensure the process continues if the shell is disconnected
    # and redirect output to a log file for debugging.
    nohup npm run preview > /tmp/astro-preview.log 2>&1 &
    PREVIEW_PID=$!

    # A common pitfall is not waiting for the server to be ready.
    # We'll poll the port until it's responsive.
    echo "--> Waiting for preview server to be available on port 4321..."
    SECONDS=0
    until curl --output /dev/null --silent --head --fail http://localhost:4321; do
        printf '.'
        sleep 1
        if [ $SECONDS -ge 60 ]; then
            echo "Error: Timed out waiting for Astro preview server to start."
            echo "--- Preview server logs ---"
            cat /tmp/astro-preview.log
            kill -9 $PREVIEW_PID
            exit 1
        fi
    done
    echo "--> Preview server is up and running."

    echo "--> Executing Cypress E2E tests in headless mode"
    # We wrap the test run in Xvfb to provide a virtual display for the browser
    xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" npm run cy:run

    echo "--> Cypress tests completed. Shutting down preview server."
    kill -9 $PREVIEW_PID
}

function finalize_image() {
    echo "--> Finalizing image for production"
    echo "--> Moving built assets to final destination"
    mkdir -p ${APP_DIR}
    mv ${BUILD_DIR}/dist ${APP_DIR}/

    echo "--> Configuring Nginx to serve the application"
    # A robust implementation would use a template file.
    cat <<EOF > /etc/nginx/sites-available/default
server {
    listen 80 default_server;
    listen [::]:80 default_server;

    root ${APP_DIR}/dist;
    index index.html;

    server_name _;

    location / {
        try_files \$uri \$uri/ =404;
    }
}
EOF
    systemctl restart nginx
    systemctl enable nginx

    echo "--> Cleaning up build artifacts"
    rm -rf ${BUILD_DIR}
    apt-get clean
    rm -rf /var/lib/apt/lists/*
    echo "--- Provisioning Complete at $(date) ---"
}


# --- Main Execution ---
install_system_deps
install_node
setup_app
run_validation_tests
finalize_image

exit 0

The corresponding packer.pkr.hcl file becomes much cleaner, delegating all the complex logic to the script. It now focuses on configuration and passing variables.

// packer.pkr.hcl - Stage 2: Validated Artifact Build
packer {
  required_plugins {
    amazon = {
      version = ">= 1.2.0"
      source  = "github.com/hashicorp/amazon"
    }
  }
}

variable "aws_region" {
  type    = string
  description = "The AWS region to build the AMI in."
}

variable "app_version" {
  type    = string
  description = "The version of the application being built."
}

variable "graphql_api_endpoint" {
  type = string
  description = "The endpoint for the GraphQL API."
  sensitive = true
}

source "amazon-ebs" "astro-app-validated" {
  ami_name      = "astro-app-validated-${var.app_version}-${timestamp()}"
  instance_type = "t3.medium" // Increased size for build/test resources
  region        = var.aws_region
  source_ami_filter {
    filters = {
      name                = "ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    most_recent = true
    owners      = ["099720109477"] // Canonical's owner ID
  }
  ssh_username = "ubuntu"
  tags = {
    Name    = "Astro App ${var.app_version}"
    Source  = "Packer"
    Version = var.app_version
  }
}

build {
  name    = "astro-app-build"
  sources = ["source.amazon-ebs.astro-app-validated"]

  provisioner "file" {
    source      = "./scripts/"
    destination = "/tmp/"
  }

  provisioner "shell" {
    environment_vars = [
      "GRAPHQL_ENDPOINT=${var.graphql_api_endpoint}"
    ]
    script = "/tmp/provision.sh"
    execute_command = "sudo -E bash '{{.Path}}'"
  }
}

Notice the instance_type was increased to t3.medium. Running a headless browser and a Node.js build process simultaneously is resource-intensive. A t3.micro would likely struggle, leading to slow or failed builds. This is a practical trade-off in a real-world project: build cost versus reliability and speed.

The application code itself needs to be aware of this build-time configuration. For instance, our Astro component fetching data via urql relies on the environment variable we injected.

// src/components/FeaturedArticles.astro
---
import { createClient, fetchExchange } from '@urql/core';

// This environment variable is baked into the code during `npm run build` inside Packer.
const graphqlEndpoint = import.meta.env.PUBLIC_GRAPHQL_ENDPOINT;

if (!graphqlEndpoint) {
  // This check is crucial. If the env var is missing, the build should ideally fail.
  // A good practice is to have a pre-build script that validates all required env vars.
  throw new Error("Application build failed: PUBLIC_GRAPHQL_ENDPOINT is not defined.");
}

const client = createClient({
  url: graphqlEndpoint,
  exchanges: [fetchExchange],
});

const ARTICLES_QUERY = `
  query GetFeaturedArticles {
    featuredArticles(limit: 5) {
      id
      title
      slug
      author {
        name
      }
    }
  }
`;

const result = await client.query(ARTICLES_QUERY, {}).toPromise();
const articles = result.data?.featuredArticles || [];
---
<div class="articles-container">
  <h2>Featured Articles</h2>
  {articles.length > 0 ? (
    <ul>
      {articles.map(article => (
        <li>
          <a href={`/articles/${article.slug}`}>{article.title} by {article.author.name}</a>
        </li>
      ))}
    </ul>
  ) : (
    <p>No featured articles available at this moment.</p>
  )}
  <!-- A specific data attribute for Cypress to target -->
  <div data-cy="articles-loaded-indicator" style="display: none;"></div>
</div>

Finally, the Cypress test must be configured to run in this specific, headless environment. The cypress.config.js file is key.

// cypress.config.js
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    // This is the URL the preview server will run on inside the Packer instance.
    baseUrl: 'http://localhost:4321',
    // We don't need video recording in CI, it just consumes space and time.
    video: false,
    // Use a smaller viewport for headless runs.
    viewportWidth: 1280,
    viewportHeight: 720,
    // Higher timeouts can be necessary in resource-constrained CI environments.
    defaultCommandTimeout: 10000,
    setupNodeEvents(on, config) {
      // implement node event listeners here
      on('task', {
        log(message) {
          console.log(message);
          return null;
        },
      });
    },
  },
  // Adding a specific script for CI runs in package.json
  // "cy:run": "cypress run --browser chrome --headless"
});

A sample Cypress test would then validate the entire data flow, from the component rendering to the GraphQL call populating the data.

// cypress/e2e/home.cy.js
describe('Homepage Data Flow', () => {
  it('should fetch and display featured articles from the GraphQL API', () => {
    // A common mistake is to not mock API calls. Here, we are intentionally
    // hitting the REAL dev/staging API endpoint baked into the build to perform
    // a true integration test. This is a conscious architectural decision.
    cy.visit('/');

    cy.get('h2').contains('Featured Articles').should('be.visible');

    // We check that the list contains items, verifying the API call was successful.
    // The timeout is increased because the initial API call might be slow.
    cy.get('.articles-container ul li', { timeout: 15000 }).should('have.length.greaterThan', 0);

    // We can even check the content of the first article link.
    cy.get('.articles-container ul li a').first().should('have.attr', 'href').and('include', '/articles/');

    // Verify our test indicator is present.
    cy.get('[data-cy="articles-loaded-indicator"]').should('exist');
  });
});

This integrated approach, while more complex to set up, provides immense value. Each AMI produced is a high-confidence release candidate. It has not only been built but has also passed a suite of tests that simulate real user interaction in a production-like server environment. The “it built on my machine” class of problems is entirely eliminated.

The primary limitation of this architecture is the build time and cost. Each Packer run launches an EC2 instance, installs a full browser and testing suite, runs tests, and then creates an AMI. This can take several minutes. For rapid development cycles, this is too slow. Therefore, this pipeline is best suited for release candidate builds targeting staging or production environments, not for every commit. A potential optimization path involves creating a custom base AMI that comes pre-installed with Node.js, Cypress dependencies, and other system tools. This would shave significant time off the provisioning step, leaving only the application-specific build and test phases to run on each invocation. Another avenue for exploration is replacing the full VM build with a container-based build using Packer’s Docker builder, which could be faster, though it introduces a different set of environmental assumptions.


  TOC