Automated AWS Infrastructure Testing
Build a CI/CD pipeline that validates AWS deployments using Docker and Floci.
Introduction
30 Second Summary
A broken deployment to production can take down an entire service in seconds. The best DevOps teams catch infrastructure failures before code ever leaves the developer's machine.
In this project, you will build a complete CI/CD pipeline that automatically tests your Docker container deployments against emulated AWS services. All locally, all free, all automated.
What You'll Build
A shift-left infrastructure testing pipeline that validates ECR image pushes and ECS task registrations against Floci, a local AWS emulator.
By the end of this project, you'll have:
- A Dockerized Node.js API with a health check endpoint and a production-ready Dockerfile.
- A local AWS emulation environment using Floci that tests ECR pushes and ECS task definitions at zero cost.
- A GitHub Actions workflow that automatically validates your full infrastructure deployment on every push.
- Secret Mission: Add multi-service deployment tests that validate DynamoDB table creation and S3 artifact storage alongside container deployment.
Are there any prerequisites?
This project assumes basic familiarity with Docker, Git, and command-line tools. You do not need prior AWS experience. Floci handles all the AWS emulation locally on your machine.
Before We Start
Before We Start...
Set Up Tools and Initialize the Project
Before you can build a CI/CD pipeline that validates AWS infrastructure, you need the right tools in place.
In this step, you will verify your existing tools, install the AWS CLI, pull the Floci Docker image, and create your project repository.
In this step, get ready to:
- Verify Docker, Git, Node.js, and install AWS CLI v2.
- Pull the Floci Docker image and verify it starts.
- Initialize the project repository and connect it to GitHub.
Verify existing tools and install AWS CLI
Your machine already has Docker, Git, and Node.js installed. A quick version check confirms each tool is accessible from your terminal.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Verify Docker is available by running:
docker --version
- Verify Node.js is available by running:
node --version
- Verify Git is available by running:
git --version
You should see a version number printed for each command.
Don't see a version number?
If any command returns an error, re-install the missing tool before continuing. Docker Desktop, Node.js, and Git must all be on your system PATH.
help me troubleshoot why Docker, Node.js, or Git isn't found in my terminal
Next, you need the AWS CLI to interact with AWS services (and later, Floci's emulated versions of them).
- Check if AWS CLI is already installed by running:
aws --version
✔️ I see aws-cli/2.x.x
AWS CLI v2 is already installed. You are good to go.
ⓧ I see aws-cli/1.x.x
You have version 1 installed. You need version 2 for this project.
Follow the platform-specific instructions below to upgrade to AWS CLI v2.
macOS
- Download and install AWS CLI v2 by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
The installer will prompt for your password. Enter it to proceed.
Windows
- Download and run the MSI installer from:
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi
Follow the on-screen prompts to complete the installation.
- After installing, close and reopen your terminal, then verify with:
aws --version
You should now see a version starting with aws-cli/2.
ⓧ Command not found
AWS CLI is not installed yet. Follow the platform-specific instructions below.
macOS
- Download and install AWS CLI v2 by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
The installer will prompt for your password. Enter it to proceed.
Windows
- Download and run the MSI installer from:
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi
Follow the on-screen prompts to complete the installation.
- After installing, close and reopen your terminal, then verify with:
aws --version
You should now see a version starting with aws-cli/2.
What is the AWS CLI?
The AWS CLI is a command-line tool that lets you interact with AWS services directly from your terminal. In this project, you will use it to talk to Floci's emulated AWS services like ECR and ECS.
Still not seeing aws-cli/2 after installing?
Make sure you closed and reopened your terminal after installation. Some systems require a fresh shell session to pick up newly installed commands.
help me troubleshoot why aws --version doesn't work after installing AWS CLI v2
Pull the Floci Docker image and verify it starts
Floci is a free, MIT-licensed local AWS emulator. It provides emulated versions of AWS services like ECR, ECS, S3, and DynamoDB so you can test deployments without spending money.
Why Floci instead of LocalStack?
LocalStack Community Edition was sunset in March 2026, requiring auth tokens and freezing security updates. Floci is MIT-licensed with no feature gates or authentication tokens. It starts in about 24 ms and uses only ~90 MB of disk space.
- Pull the Floci Docker image by running:
docker pull floci/floci:latest
You should see Docker downloading image layers and finishing with a success message.
- Start a test container to confirm Floci works by running:
docker run -d --name floci-test -p 4566:4566 -v //var/run/docker.sock:/var/run/docker.sock floci/floci:latest
What does this command do?
`-d` runs the container in the background. `-p 4566:4566` maps Floci's default port to your machine.
`-v //var/run/docker.sock:/var/run/docker.sock` gives Floci access to your Docker daemon so it can run real containers for ECS emulation.
- Verify Floci is healthy by running:
curl http://localhost:4566/_localstack/health
You should see a JSON response containing the word running. This confirms Floci is alive and ready to emulate AWS services.
Seeing connection refused?
Floci takes a moment to start. Wait a few seconds and try the curl command again. If it still fails, check that Docker Desktop is running and that port 4566 is not already in use by another process.
On Windows, if curl is not available, open http://localhost:4566/_localstack/health in your browser instead.
help me troubleshoot why Floci isn't responding on port 4566
- Stop and remove the test container by running:
docker stop floci-test && docker rm floci-test
You will start Floci properly with Docker Compose in a later step. This was just a quick health check.
Initialize the project repository
Now that your tools are verified, it is time to create the project directory and initialize it as both a Git repository and a Node.js project.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, initialize Git, and initialize npm by running these commands:
mkdir infra-test-pipeline
cd infra-test-pipeline
git init
npm init -y
What does this set up?
`git init` creates a new Git repository so you can track changes and push to GitHub. `npm init -y` creates a package.json file with default values, which is the manifest for your Node.js project.
You should see a message confirming the Git repository was initialized and a package.json file was created.
- Confirm the files exist by running:
ls
You should see package.json in the output.
Next, create a GitHub repository to host your code and trigger CI/CD workflows.
- Open your browser and go to https://github.com/new.
- Enter infra-test-pipeline as the repository name.
- Leave the repository set to Public (this gives you unlimited GitHub Actions minutes).
- Do not check any of the initialization boxes (README, .gitignore, license).
- Click Create repository.
Why keep it public?
Public repositories on GitHub get unlimited free Actions minutes. Private repositories on the free plan are limited to 2,000 minutes per month. Since this is a learning project, public keeps things simple and free.
- Back in your terminal, connect your local repository to GitHub by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/infra-test-pipeline.git"]]
Copy the URL from the GitHub page that appeared after creating your repository. It will look like https://github.com/ followed by your username and /infra-test-pipeline.git.
Seeing 'remote origin already exists'?
This means a remote named origin was already added. You can update it instead:
git remote set-url origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/infra-test-pipeline.git"]]
help me fix the 'remote origin already exists' error when connecting my Git repo to GitHub
✔️ Awesome, I've got everything!
Great. Double check that you have saved your files and that your terminal is inside the infra-test-pipeline directory.
ⓧ I'd like to double check the full code
Your package.json should look like this:
{
"name": "infra-test-pipeline",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
Your environment is ready. Next up, you will build the Node.js API and Dockerfile that your pipeline will validate.
Build the Node.js API and Dockerfile
Your tools are installed and the project folder is ready. Now you need an application to deploy.
In this step, you will build a Node.js REST API with a health check endpoint, then package it into a production-ready Docker image. The health endpoint is what your infrastructure validation script will use later to confirm successful deployments.
In this step, get ready to:
- Create an Express API with health check and root endpoints.
- Write a multi-stage Dockerfile for a production-ready container.
- Build and test the Docker image locally.
Create the Express API with a health check endpoint
Your API only needs two routes. A /health endpoint returns the service status, and a root / endpoint returns basic service info.
- Install Express by running this command in your terminal:
npm install express@4
What does this command do?
This installs Express version 4, a lightweight web framework for Node.js. It adds express to your package.json dependencies and downloads it into node_modules.
- Create the src directory by running:
mkdir src
- Create a new file called server.js inside the src folder, then paste the following code into it:
const express = require("express");
const app = express();
const PORT = process.env.PORT || 3000;
// Parse incoming JSON request bodies
app.use(express.json());
// Health check endpoint - returns service status
app.get("/health", (req, res) => {
res.status(200).json({
status: "healthy",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || "1.0.0",
});
});
// Root endpoint - returns basic service info
app.get("/", (req, res) => {
res.status(200).json({
service: "infra-test-app",
message: "Infrastructure Test Pipeline API",
});
});
// Start the server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What does this code do?
- GET /health returns a JSON object with the service status, a timestamp, and a version number. Your validation script will call this endpoint to confirm the app is alive.
- GET / returns the service name and a description. This gives a quick identity check for anyone hitting the root URL.
- process.env.PORT || 3000 lets the port be overridden by an environment variable, which is standard practice for containerized apps.
- Save src/server.js.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
const express = require("express");
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.get("/health", (req, res) => {
res.status(200).json({
status: "healthy",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || "1.0.0",
});
});
app.get("/", (req, res) => {
res.status(200).json({
service: "infra-test-app",
message: "Infrastructure Test Pipeline API",
});
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Now you need to update package.json so it points to your new server file and includes a start script.
- Open package.json in your code editor and replace its entire contents with the following:
{
"name": "infra-test-pipeline",
"version": "1.0.0",
"description": "Infrastructure Testing Pipeline with Floci",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"express": "^4.21.0"
}
}
What changed in package.json?
- "main": "src/server.js" tells Node.js where the entry point lives.
- "start": "node src/server.js" lets you (and Docker) run the app with npm start.
- The dependencies field pins Express to the version you installed.
- Save package.json.
✔️ Awesome, I've got everything!
Make sure the file is saved. Next up, you will containerize this app.
ⓧ I'd like to double check the full code
{
"name": "infra-test-pipeline",
"version": "1.0.0",
"description": "Infrastructure Testing Pipeline with Floci",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"express": "^4.21.0"
}
}
Seeing module not found errors?
If you test the server now and get a module error, make sure you ran npm install express@4 earlier. Check that a node_modules folder exists in your infra-test-pipeline directory.
Why is my Express server not starting?
Create a production Dockerfile
A multi-stage Dockerfile keeps your final image small by separating the build step from the production image. Only the production dependencies end up in the container that gets deployed.
- Create a new file called Dockerfile in the root of your infra-test-pipeline folder (not inside src), then paste the following:
# Stage 1: Install production dependencies
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Create the production image
FROM node:24-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=build /app/node_modules ./node_modules
COPY src/ ./src/
COPY package.json ./
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
USER appuser
CMD ["node", "src/server.js"]
What does this Dockerfile do?
- Stage 1 (build) installs only production dependencies using npm ci. This stage is thrown away after the build.
- Stage 2 copies just the node_modules from stage 1, plus your source code. No build tools or dev dependencies make it into the final image.
- USER appuser runs the container as a non-root user. This is a security best practice for production containers.
- node:24-alpine is a minimal Linux image that keeps the container size small.
- Save Dockerfile.
- Confirm your project structure by running:
ls
You should see Dockerfile, package.json, node_modules, and src in the output.
✔️ Awesome, I've got everything!
Nice. Your Dockerfile is ready to build.
ⓧ I'd like to double check the full code
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:24-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=build /app/node_modules ./node_modules
COPY src/ ./src/
COPY package.json ./
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
USER appuser
CMD ["node", "src/server.js"]
Missing a file?
If you don't see Dockerfile in the output, make sure you created it in the infra-test-pipeline folder and not inside src/. Also check that the filename has no extension. It should be exactly Dockerfile with a capital D.
Help me fix my project file structure.
Test the Docker build locally
Before wiring this into a CI/CD pipeline, you should confirm the image builds and the container serves traffic correctly.
- Build the Docker image by running this command:
docker build -t infra-test-app:local .
What does this command do?
docker build reads your Dockerfile and creates an image. The -t infra-test-app:local flag names the image infra-test-app with a local tag. The . at the end tells Docker to use the current directory as the build context.
- You should see output ending with a line that says the image was successfully tagged.
Build failing?
If you see an error about package.json not found, make sure you are running the command from inside the infra-test-pipeline folder where your Dockerfile lives.
If you see a permissions error, confirm Docker Desktop is running. You can check by looking for the Docker icon in your system tray.
Help me debug my Docker build failure.
- Start a container from your new image by running:
docker run -d -p 3000:3000 --name test-app infra-test-app:local
What does this command do?
- -d runs the container in the background (detached mode).
- -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container.
- --name test-app gives the container a friendly name so you can easily stop it later.
- Verify the health endpoint by running:
curl http://localhost:3000/health
You should see a JSON response containing "status":"healthy" along with a timestamp and version number.
Connection refused or empty reply?
The container may need a second to start. Wait a moment, then try the curl command again.
If it still fails, check the container logs by running docker logs test-app. Look for error messages about missing modules or syntax errors in your src/server.js.
My container is running but curl returns connection refused. How do I fix this?
- Clean up the test container by running:
docker stop test-app && docker rm test-app
This stops and removes the container so port 3000 is free for future tests.
Your API is built, containerized, and verified. Next, you will deploy this image to Floci's emulated AWS services and register it as an ECS task.
Deploy to Floci's Emulated AWS
Your Dockerized API is built and ready to deploy. The next question is: how do you prove it actually works in an AWS-like environment without spending a cent?
In this step, you will deploy your container to Floci's emulated AWS services. You will push your Docker image to an emulated ECR registry and register an ECS task definition, just like a real production deployment.
In this step, get ready to:
- Start Floci with Docker socket access using Docker Compose.
- Push your Docker image to Floci's emulated ECR registry.
- Register an ECS task definition and create a cluster.
Start Floci with Docker Compose
Instead of running a long docker run command every time you need Floci, you will define the service in a compose.yaml file. This makes starting and stopping your emulated AWS environment repeatable and consistent.
- Create a new file called compose.yaml in the root of your infra-test-pipeline folder, then paste the following configuration:
services:
floci:
image: floci/floci:latest
ports:
- "4566:4566"
volumes:
- //var/run/docker.sock:/var/run/docker.sock
environment:
- FLOCI_STORAGE_MODE=memory
What does this configuration do?
- image: floci/floci:latest pulls the Floci image you already downloaded in Step 1.
- ports: "4566:4566" maps the Floci port so your AWS CLI commands can reach it at localhost:4566.
- volumes gives Floci access to Docker's socket so it can manage real containers for ECS emulation.
- FLOCI_STORAGE_MODE=memory keeps all data in memory for fast, ephemeral testing.
- Save compose.yaml.
- Start Floci in the background by running this command:
docker compose up -d
You should see output indicating the Floci container has started.
- Verify Floci is healthy by running:
curl http://localhost:4566/_localstack/health
- You should see a JSON response containing "running" in the output.
Floci not starting?
- Make sure Docker Desktop is running. Floci needs the Docker daemon active to start.
- Check that port 4566 is not already in use. If you left the test container from Step 1 running, stop it first.
- On macOS or Linux, change //var/run/docker.sock to /var/run/docker.sock (single slash) in your compose.yaml if you see a volume mount error.
Help me troubleshoot Floci not starting with Docker Compose.
Now you need to tell your AWS CLI to talk to Floci instead of real AWS. You do this by setting environment variables that redirect all AWS API calls to localhost:4566.
- Set the AWS environment variables by running these commands:
macOS
export AWS_ENDPOINT_URL=http://localhost:4566
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
Windows
set AWS_ENDPOINT_URL=http://localhost:4566
set AWS_DEFAULT_REGION=us-east-1
set AWS_ACCESS_KEY_ID=test
set AWS_SECRET_ACCESS_KEY=test
Linux
export AWS_ENDPOINT_URL=http://localhost:4566
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
Why are the credentials set to test?
Floci does not validate credentials. It accepts any value for AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. Setting them to test satisfies the AWS CLI's requirement for credentials while making it clear these are not real keys.
The AWS_ENDPOINT_URL variable redirects all CLI calls to Floci on port 4566 instead of the real AWS endpoints.
Create an ECR repository and push your Docker image
With Floci running and your CLI pointed at it, you can now create an ECR repository and push your Docker image. This is exactly what you would do with a real AWS account.
- Create the ECR repository by running:
aws ecr create-repository --repository-name infra-test-app
- You should see JSON output containing "repositoryName": "infra-test-app" confirming the repository was created.
- Tag your local Docker image for the Floci ECR registry by running:
docker tag infra-test-app:local localhost:4566/infra-test-app:latest
- Push the tagged image to Floci's ECR by running:
docker push localhost:4566/infra-test-app:latest
- You should see layer push progress followed by a latest: digest: sha256:... confirmation line.
- Verify the image exists in ECR by running:
aws ecr describe-images --repository-name infra-test-app
- You should see JSON output listing your pushed image with its digest and tag.
What is ECR?
ECR (Elastic Container Registry) is AWS's managed Docker image registry. In production, you push your container images to ECR so that ECS can pull and run them.
Floci emulates ECR using a real OCI-compatible registry behind the scenes, so docker push and docker pull work exactly as they would against real AWS.
Push command failing?
- Make sure Floci is still running. Run docker compose ps to check its status.
- Verify your environment variables are set. Run echo %AWS_ENDPOINT_URL% (Windows) or echo $AWS_ENDPOINT_URL (macOS/Linux) to confirm it shows http://localhost:4566.
- If you see a connection refused error, wait a few seconds for Floci to finish initializing, then try again.
Help me fix my Docker push to Floci's emulated ECR.
Register an ECS task definition and create a cluster
The final piece of a container deployment is telling ECS how to run your image. You do this with a task definition, a JSON file that specifies the container image, resources, ports, and environment variables.
- Create a new file called task-definition.json in the root of your infra-test-pipeline folder, then paste the following configuration:
{
"family": "infra-test-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"containerDefinitions": [
{
"name": "infra-test-app",
"image": "000000000000.dkr.ecr.us-east-1.localhost:4566/infra-test-app:latest",
"essential": true,
"portMappings": [
{
"containerPort": 3000,
"protocol": "tcp"
}
],
"environment": [
{
"name": "NODE_ENV",
"value": "production"
},
{
"name": "PORT",
"value": "3000"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/infra-test-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
What does this task definition configure?
- family groups related task definition versions under the name infra-test-app.
- requiresCompatibilities: ["FARGATE"] tells ECS this task runs on Fargate (serverless containers).
- cpu and memory allocate 256 CPU units (0.25 vCPU) and 512 MB of RAM.
- containerDefinitions specifies the image to pull, port mappings, environment variables, and log configuration.
- The image URL uses Floci's local ECR format: 000000000000.dkr.ecr.us-east-1.localhost:4566.
- Save task-definition.json.
- Create an ECS cluster by running:
aws ecs create-cluster --cluster-name infra-test-cluster
- You should see JSON output with "clusterName": "infra-test-cluster" and a status of ACTIVE.
- Register the task definition by running:
aws ecs register-task-definition --cli-input-json file://task-definition.json
- Verify the task definition was registered by running:
aws ecs list-task-definitions
- You should see output containing infra-test-app:1 in the task definition ARN list.
What is ECS?
ECS (Elastic Container Service) is AWS's container orchestration service. It pulls images from ECR, runs them according to task definitions, and manages scaling and health checks.
Floci emulates ECS clusters and task definitions using real Docker containers under the hood.
Task definition not registering?
- Check that task-definition.json is saved in the root of your infra-test-pipeline folder (same directory where you run the command).
- Validate the JSON syntax. A missing comma or bracket causes the registration to fail.
- Make sure your AWS environment variables are still set in the current terminal session. Opening a new terminal resets them.
Help me troubleshoot my ECS task definition not registering.
✔️ Awesome, I've got everything!
Great. Double check you have saved both compose.yaml and task-definition.json.
ⓧ I'd like to double check the full code
Here is the complete compose.yaml:
services:
floci:
image: floci/floci:latest
ports:
- "4566:4566"
volumes:
- //var/run/docker.sock:/var/run/docker.sock
environment:
- FLOCI_STORAGE_MODE=memory
Here is the complete task-definition.json:
{
"family": "infra-test-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"containerDefinitions": [
{
"name": "infra-test-app",
"image": "000000000000.dkr.ecr.us-east-1.localhost:4566/infra-test-app:latest",
"essential": true,
"portMappings": [
{
"containerPort": 3000,
"protocol": "tcp"
}
],
"environment": [
{
"name": "NODE_ENV",
"value": "production"
},
{
"name": "PORT",
"value": "3000"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/infra-test-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
You have a fully deployed container stack running against emulated AWS. Next up, you will wrap this entire flow into a validation script that can run automatically and catch failures before they reach production.
Create the Infrastructure Validation Script
You have a working deployment flow. You can build a Docker image, push it to Floci's emulated ECR, and register an ECS task definition. The problem is that you did all of this manually.
In this step, you will wrap the entire deployment flow into a single bash script that validates each stage automatically. If any validation check fails, the script exits with an error code that tells your CI/CD pipeline something is broken.
In this step, get ready to:
- Write a bash validation script that tests the full ECR, Docker, and ECS deployment flow.
- Add error handling with pass/fail reporting and exit codes.
- Test the validation script locally against Floci.
Write the validation script
The validation script will recreate the deployment flow you ran manually in the previous step. It tests each stage (ECR, Docker build/push, ECS) and reports whether each check passed or failed.
- Create a scripts directory inside your infra-test-pipeline folder and confirm it exists by running these commands:
mkdir scripts
ls
You should see scripts listed in the output alongside your other project files.
- Create a file called validate-infra.sh inside the scripts folder. Open it in your editor and paste the following code:
#!/bin/bash
set -e
echo "=== Infrastructure Validation Script ==="
echo ""
export AWS_ENDPOINT_URL=http://localhost:4566
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
PASS=0
FAIL=0
check() {
local description=$1
shift
if "$@" > /dev/null 2>&1; then
echo "[PASS] $description"
PASS=$((PASS + 1))
else
echo "[FAIL] $description"
FAIL=$((FAIL + 1))
fi
}
echo "--- ECR Validation ---"
aws ecr create-repository --repository-name infra-test-app 2>/dev/null || true
check "ECR repository exists" aws ecr describe-repositories --repository-names infra-test-app
echo ""
echo "--- Docker Build and Push ---"
docker build -t infra-test-app:ci .
docker tag infra-test-app:ci localhost:4566/infra-test-app:latest
check "Docker image built" docker image inspect infra-test-app:ci
docker push localhost:4566/infra-test-app:latest 2>/dev/null || true
check "Image pushed to ECR" aws ecr list-images --repository-name infra-test-app
echo ""
echo "--- ECS Validation ---"
aws ecs create-cluster --cluster-name infra-test-cluster 2>/dev/null || true
check "ECS cluster exists" aws ecs describe-clusters --clusters infra-test-cluster
aws ecs register-task-definition --cli-input-json file://task-definition.json 2>/dev/null || true
check "Task definition registered" aws ecs list-task-definitions --family-prefix infra-test-app
echo ""
echo "=== Results ==="
echo "Passed: $PASS"
echo "Failed: $FAIL"
echo ""
if [ $FAIL -gt 0 ]; then
echo "INFRASTRUCTURE VALIDATION FAILED"
exit 1
else
echo "INFRASTRUCTURE VALIDATION PASSED"
exit 0
fi
What does this script do?
The script has four main sections that run sequentially:
- Setup exports the AWS environment variables pointing at Floci and initializes PASS and FAIL counters to track results.
- ECR Validation creates the repository (or skips if it already exists) and verifies it can be described.
- Docker Build and Push builds your image with the :ci tag, pushes it to Floci's ECR, and verifies both operations.
- ECS Validation creates a cluster, registers the task definition, and verifies both are queryable.
- Save scripts/validate-infra.sh.
File not saving in the right place?
- Make sure the file is saved inside the scripts folder, not in the root infra-test-pipeline folder.
- The full path should be infra-test-pipeline/scripts/validate-infra.sh.
Still stuck? Help me figure out why my validate-infra.sh file isn't in the right location.
Understand the error handling and exit codes
The script is designed to work inside a CI/CD pipeline. Pipelines rely on exit codes to determine whether a step passed or failed.
How does the error handling work?
The check() function is the core of the error handling pattern. It takes a description string and a command, runs the command silently, and increments either PASS or FAIL depending on the command's exit code.
The 2>/dev/null || true pattern on create commands suppresses error output if a resource already exists. This makes the script idempotent. You can run it multiple times without failures from duplicate resources.
At the end, exit 1 signals failure and exit 0 signals success. GitHub Actions (and all CI systems) use these codes to mark a pipeline run as red or green.
Why is idempotency important for CI?
Every push to your repository triggers a fresh pipeline run. If the script crashed because a resource "already exists," your pipeline would fail on every run after the first. The || true pattern ensures create commands succeed regardless of prior state.
Test the validation script locally
Floci should still be running from the previous step. The script sets its own AWS environment variables internally, so you can run it directly.
- Run the validation script from your infra-test-pipeline folder by executing this command:
bash scripts/validate-infra.sh
You should see output showing each section with [PASS] next to every check, followed by a summary and INFRASTRUCTURE VALIDATION PASSED at the end.
Seeing FAIL on any check?
- Make sure Floci is still running. Run docker compose up -d to restart it if needed.
- Confirm you are running the script from the infra-test-pipeline folder (the same folder that contains your Dockerfile and task-definition.json).
- If the Docker build fails, check that Docker Desktop is running and the Docker daemon is available.
Still stuck? Help me debug why my validation script is failing against Floci.
✔️ Awesome, I've got everything!
Great. Double check you have saved scripts/validate-infra.sh and that all checks pass when you run the script.
ⓧ I'd like to double check the full code
#!/bin/bash
set -e
echo "=== Infrastructure Validation Script ==="
echo ""
export AWS_ENDPOINT_URL=http://localhost:4566
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
PASS=0
FAIL=0
check() {
local description=$1
shift
if "$@" > /dev/null 2>&1; then
echo "[PASS] $description"
PASS=$((PASS + 1))
else
echo "[FAIL] $description"
FAIL=$((FAIL + 1))
fi
}
echo "--- ECR Validation ---"
aws ecr create-repository --repository-name infra-test-app 2>/dev/null || true
check "ECR repository exists" aws ecr describe-repositories --repository-names infra-test-app
echo ""
echo "--- Docker Build and Push ---"
docker build -t infra-test-app:ci .
docker tag infra-test-app:ci localhost:4566/infra-test-app:latest
check "Docker image built" docker image inspect infra-test-app:ci
docker push localhost:4566/infra-test-app:latest 2>/dev/null || true
check "Image pushed to ECR" aws ecr list-images --repository-name infra-test-app
echo ""
echo "--- ECS Validation ---"
aws ecs create-cluster --cluster-name infra-test-cluster 2>/dev/null || true
check "ECS cluster exists" aws ecs describe-clusters --clusters infra-test-cluster
aws ecs register-task-definition --cli-input-json file://task-definition.json 2>/dev/null || true
check "Task definition registered" aws ecs list-task-definitions --family-prefix infra-test-app
echo ""
echo "=== Results ==="
echo "Passed: $PASS"
echo "Failed: $FAIL"
echo ""
if [ $FAIL -gt 0 ]; then
echo "INFRASTRUCTURE VALIDATION FAILED"
exit 1
else
echo "INFRASTRUCTURE VALIDATION PASSED"
exit 0
fi
Your validation script is working. Next up, you will wire this script into a GitHub Actions workflow so it runs automatically on every push to your repository.
Wire Into GitHub Actions
Your validation script proves that the full ECR-to-ECS deployment flow works against Floci locally. But a script you run manually is only as reliable as your memory to run it.
The real power of shift-left testing comes from automation. In this step, you'll create a GitHub Actions workflow that runs your infrastructure validation on every push. If a broken Dockerfile or misconfigured task definition slips in, the pipeline catches it before it ever touches production.
In this step, get ready to:
- Create a GitHub Actions workflow file that triggers on push and pull requests to main.
- Add steps that start Floci, build your image, and run the validation script.
- Push to GitHub and verify a green pipeline in the Actions tab.
Create the GitHub Actions workflow file
GitHub Actions discovers workflows by looking for YAML files inside a .github/workflows directory at the root of your repository.
- Create the workflow directory structure by running these commands:
mkdir -p .github/workflows
- Create a new file called infra-test.yml inside .github/workflows/ and paste the following code:
name: Infrastructure Validation
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate-infrastructure:
name: Validate AWS Infrastructure
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Start Floci
run: |
docker run -d --name floci \
-p 4566:4566 \
-v /var/run/docker.sock:/var/run/docker.sock \
-u root \
-e FLOCI_STORAGE_MODE=memory \
floci/floci:latest
What does this code do?
This is the first half of your workflow file. It defines when the pipeline triggers and how it starts the testing environment.
- on: push/pull_request triggers the workflow whenever code is pushed or a PR is opened against main.
- runs-on: ubuntu-latest tells GitHub to run the job on a fresh Ubuntu VM with 2 CPUs and 8 GB RAM.
- actions/checkout@v4 clones your repository code onto the runner so subsequent steps can access it.
- The Start Floci step launches the Floci container with Docker socket access (-u root) so it can manage containers for ECR emulation.
Add the build and validate steps
With Floci starting up, you need steps that wait for it to be healthy, build your Docker image, run the validation script, and clean up afterwards.
- In .github/workflows/infra-test.yml, add these steps directly below the Start Floci step (keep the indentation aligned with the other steps):
- name: Wait for Floci to be ready
run: |
echo "Waiting for Floci..."
for i in $(seq 1 30); do
if curl -s http://localhost:4566/_localstack/health | grep -q "running"; then
echo "Floci is ready!"
break
fi
sleep 1
done
- name: Build Docker image
run: docker build -t infra-test-app:ci .
- name: Run infrastructure validation
run: |
chmod +x scripts/validate-infra.sh
bash scripts/validate-infra.sh
- name: Cleanup
if: always()
run: docker stop floci && docker rm floci
What does this code do?
- The Wait step polls Floci's health endpoint (/_localstack/health) every second for up to 30 seconds. Floci typically starts in about 24 ms, but the loop accounts for slower CI environments.
- Build Docker image builds your app using the same Dockerfile you tested locally.
- Run infrastructure validation makes the script executable and runs it. The script handles ECR push, ECS registration, and reports pass/fail.
- if: always() on the Cleanup step ensures the Floci container is removed even if earlier steps fail.
- Save .github/workflows/infra-test.yml.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
name: Infrastructure Validation
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate-infrastructure:
name: Validate AWS Infrastructure
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Start Floci
run: |
docker run -d --name floci \
-p 4566:4566 \
-v /var/run/docker.sock:/var/run/docker.sock \
-u root \
-e FLOCI_STORAGE_MODE=memory \
floci/floci:latest
- name: Wait for Floci to be ready
run: |
echo "Waiting for Floci..."
for i in $(seq 1 30); do
if curl -s http://localhost:4566/_localstack/health | grep -q "running"; then
echo "Floci is ready!"
break
fi
sleep 1
done
- name: Build Docker image
run: docker build -t infra-test-app:ci .
- name: Run infrastructure validation
run: |
chmod +x scripts/validate-infra.sh
bash scripts/validate-infra.sh
- name: Cleanup
if: always()
run: docker stop floci && docker rm floci
Why run Floci directly instead of Docker Compose?
On the GitHub Actions ubuntu-latest runner, Docker is pre-installed but running docker compose adds a dependency on the Compose plugin being present. Using docker run directly keeps the workflow simpler and avoids version mismatches.
Your local compose.yaml is still useful for development. The CI workflow just doesn't depend on it.
Push to GitHub and verify the pipeline runs
Your workflow file is ready. Now you need to commit everything and push to trigger the pipeline.
- Stop your local Floci container first to free up the Docker socket by running:
docker compose down
- Stage all your project files by running:
git add .
- Commit with a descriptive message by running:
git commit -m "Add infrastructure validation pipeline"
- Push to your remote repository by running:
git push origin main
Seeing a rejected push?
If Git rejects the push because the remote has changes you don't have locally, pull first with git pull origin main --rebase and then push again.
If your default branch is called master instead of main, update the workflow's branch filter to match, or rename your branch with git branch -M main.
help me push my code to GitHub
- Open your GitHub repository in your browser.
- Click the Actions tab at the top of the repository.
- You should see a workflow run named Infrastructure Validation either running or completed with a green checkmark.
- Click into the run, then click Validate AWS Infrastructure to see the step-by-step logs.
What should I see in the logs?
Expand the Run infrastructure validation step in the logs. You should see the same output your validation script produced locally:
- [PASS] ECR repository exists.
- [PASS] Docker image built.
- [PASS] ECS cluster exists.
- [PASS] Task definition registered.
- INFRASTRUCTURE VALIDATION PASSED at the bottom.
Pipeline showing a red X?
Click into the failed step to read the error message. Common causes:
- The Floci container didn't start in time. Check the Wait for Floci to be ready step. If it timed out, try increasing the loop count from 30 to 60.
- Permission denied on the script. Verify that chmod +x appears before the bash command in your workflow.
- Docker socket errors. Confirm your Start Floci step includes -v /var/run/docker.sock:/var/run/docker.sock and -u root.
help me debug my failing GitHub Actions pipeline
Secret mission
Multi-Service Deployment Validation
Your pipeline validates ECR and ECS, but real deployments touch more than containers. Can you extend your infrastructure tests to cover DynamoDB and S3, proving you can validate a full microservices stack in CI?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses GitHub Actions free-tier minutes. There are no ongoing costs to worry about. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local Docker containers (Floci service from Docker Compose).
- Local Docker images (infra-test-app:local, infra-test-app:ci, floci/floci:latest).
- GitHub repository (infra-test-pipeline).
- Local project folder (infra-test-pipeline on your Desktop).
- GitHub Actions workflow minutes (free for public repos, 2,000 min/month for private repos on the GitHub Free plan).
Keep everything running
No action needed. Choose this if you are still actively building on this pipeline or plan to keep pushing changes to trigger your workflow.
- Your GitHub Actions pipeline runs on every push at no cost for public repositories.
- The local Floci container only uses resources when running. It does not auto-start on reboot.
- Docker images stay cached on disk until you manually remove them.
Pause - I'll come back to this later
Shut down running containers to free up memory and CPU. Your files, images, and GitHub repo stay intact so you can pick up where you left off.
- Stop the Floci container by running:
- When you come back, run docker compose up -d from the infra-test-pipeline folder to restart Floci.
cd ~/Desktop/infra-test-pipeline
docker compose down
Your GitHub Actions pipeline still works in CI regardless of whether Floci is running locally.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Stop and remove any running containers by running:
cd ~/Desktop/infra-test-pipeline
docker compose down
- Remove the Docker images built during this project by running:
docker rmi infra-test-app:local infra-test-app:ci floci/floci:latest
- Delete the local project folder.
macOS
cd ~/Desktop
rm -rf infra-test-pipeline
Windows
cd $HOME\Desktop
rmdir /s /q infra-test-pipeline
- Delete the GitHub repository.
- Go to your infra-test-pipeline repository on GitHub.
- Click the Settings tab.
- Scroll down to the Danger Zone section at the bottom.
- Click Delete this repository and follow the confirmation prompts.
That's a Wrap!
That's a Wrap!
Nice work! You just built a complete shift-left CI/CD pipeline that catches broken AWS deployments before they ever hit production.
You've learned how to:
- Built a production-ready Dockerized Node.js API with a multi-stage Dockerfile and automated health checks.
- Deployed containers to emulated ECR and registered ECS task definitions using Floci as a free local AWS emulator.
- Automated the entire infrastructure validation flow with a GitHub Actions workflow that runs on every push to main.
- (Secret Mission) Extended your pipeline with DynamoDB and S3 validation to test multi-service deployments end-to-end.
Ready to quiz yourself?