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.