Introduction
If you've ever wondered how production teams ship code dozens of times a day without breaking things (or how they recover fast when they do), the answer almost always comes down to a solid CI/CD pipeline. In this post, I'm going to walk you through exactly how I built one end-to-end on AWS — from pushing code to a Git repository all the way through automated build, test, deploy, rollback, and finally a blue/green deployment strategy.
Here's what the full pipeline looks like at a high level:
Git Push → S3 (source) → AWS CodeBuild (build + test) → AWS CodeDeploy → EC2 (production)
↑
AWS CodePipeline orchestrates it all
Let's go step by step.
Prerequisites
Before diving in, here's what was already in place in this lab environment (you'd provision these yourself in a real project):
- An EC2 instance used as a development environment
- A self-hosted Gitea SCM (Git-based source control)
- An Auto Scaling Group with 2 EC2 production instances
- An Application Load Balancer (ALB) targeting those instances
- The CodeDeploy Agent pre-installed on production instances
- IAM roles for CodeBuild, CodeDeploy, and CodePipeline
The application itself is a simple Node.js + Express app with an AngularJS frontend. It has:
- A
gulp-based build process -
Karma+Jasmineunit tests - A dev mode (port 3000) and production mode (port 8080)
Step 1 — Committing Code to the Git Repository
The first step in any CI/CD pipeline is getting your code into source control. I connected to the EC2 dev instance via EC2 Instance Connect (browser-based SSH — no key pair needed), then:
# Navigate into the app directory and run the tests first
cd app
npm test
# Verify the app runs locally in dev mode
NODE_ENV=development DEBUG=aws-code-services:* npm start
# App is now accessible at http://<EC2-IP>:3000
Once tests passed and the app looked good, I set up Git credentials and pushed to the remote repo:
# Configure Git identity
git config --global user.email student@platform.qa.com
git config --global user.name student
git config --global credential.helper store
echo "http://student:LabPassword123@<SCM-IP>:3000" > ~/.git-credentials
# Clone the empty remote repo
git clone http://<SCM-IP>:3000/student/app-repo.git
cd app-repo
# Copy the app source into the repo
cp -R ../app/. .
# Stage, commit, and push
git add -A
git commit -m "app v1.0"
git push
At this point, app v1.0 is live in the remote SCM repository. The SCM was configured to automatically zip and upload the source to an S3 bucket (code-build-source-*) whenever a push lands — this is the bridge between the self-hosted SCM and CodeBuild, which doesn't natively support self-hosted Git.
Step 2 — Automated Build with AWS CodeBuild
With source code in S3, AWS CodeBuild picks it up and runs the build. The entire build is defined in a buildspec.yml file at the root of the project:
version: 0.2
phases:
install:
runtime-versions:
nodejs: 18
commands:
- npm install # Install ALL dependencies (including dev)
pre_build:
commands:
- npm test # Run automated unit tests
- npm prune --production # Remove dev dependencies
build:
commands:
- npm run build # Production build via gulp (minification, bundling)
artifacts:
files:
- '**/*' # Package everything for CodeDeploy
What's happening in each phase:
| Phase | What it does |
|---|---|
install |
Pulls in the Node.js runtime and installs all npm dependencies |
pre_build |
Runs unit tests; if they fail, the build stops here. Then strips dev deps. |
build |
Runs the gulp production build — minifies and bundles frontend assets |
artifacts |
Packages the entire working directory into a ZIP and uploads to S3 |
The CodeBuild project was configured to:
- Use an AWS-managed Docker container (no infrastructure to manage)
- Store build artifacts in a dedicated S3 bucket (
code-build-artifacts-*) - Log everything to Amazon CloudWatch Logs for debugging
I triggered a manual build to verify the setup, watched the phase details, and confirmed the artifact landed in S3. ✅
Step 3 — Configuring AWS CodeDeploy
This is where the actual deployment to EC2 happens. CodeDeploy uses an appspec.yml at the project root to know how to deploy:
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user/app
hooks:
ApplicationStop:
- location: scripts/stop_server.sh
timeout: 300
ApplicationStart:
- location: scripts/start_server.sh
timeout: 300
ValidateService:
- location: scripts/validate_service.sh
timeout: 300
The lifecycle hooks are critical:
-
ApplicationStop— gracefully stops any running instance of the Node server -
ApplicationStart— starts the server in production mode on port 8080 -
ValidateService— curls port 8080 and fails the deployment if the app doesn't respond
If any hook script returns a non-zero exit code, CodeDeploy fails the deployment and triggers a rollback automatically.
Creating the Deployment Application and Groups
In the CodeDeploy console, I created an application named lab-app and two deployment groups:
In-place deployment group (in-place):
- Targets the Auto Scaling Group (
lab-app-prod-asg) - Uses
CodeDeployDefault.OneAtATime— updates one instance at a time, keeping the other serving traffic - ALB integration with connection draining enabled
- Automatic rollback on failure ✅
Blue/green deployment group (blue-green):
- Same ASG and ALB configuration
- CodeDeploy provisions fresh EC2 instances for every deployment (no config drift)
- Original instances kept running until cutover completes
- Traffic switches via the ALB — zero downtime
- Automatic rollback on failure ✅
Step 4 — Wiring It All Together with AWS CodePipeline
CodePipeline is the orchestrator that connects source → build → deploy into a single automated workflow.
I created a pipeline named lab-app with these stages:
[Source] → [Build] → [Production]
S3 CodeBuild CodeDeploy (in-place)
Pipeline configuration highlights:
-
Source stage: Watches the S3 bucket (
code-build-source-*) forsource.zipchanges. When a new zip lands, the pipeline fires. -
Build stage: Delegates to the
lab-appCodeBuild project. The outputBuildArtifactis passed downstream. -
Production stage: I added this manually after creating the pipeline (you can't rename stages, so skip the default "Deploy" to avoid the misleading label). It runs a CodeDeploy action pointing at the
lab-appapplication andin-placedeployment group. Automatic rollback on stage failure is enabled.
One important note: the Pipeline checks S3 periodically for changes. In production, you'd configure EventBridge (CloudWatch Events) for near-instant triggers, or use a native SCM integration if you're on GitHub, GitLab, or BitBucket.
Step 5 — Following a Successful Deployment
With the pipeline in place, I triggered it manually via Release change and watched each stage:
-
Source → Succeeded (green) — latest
source.zippulled from S3 - Build → Succeeded — CodeBuild ran all phases, artifact uploaded
- Production → In Progress — CodeDeploy started the in-place rollout
Clicking into the CodeDeploy deployment view showed the lifecycle events per instance in real time:
BeforeAllowTraffic → ApplicationStop → ApplicationStart → ValidateService → AfterAllowTraffic
Since OneAtATime was configured, one instance was taken out of the ALB at a time, upgraded, validated, then returned to service before the second instance was touched. The app stayed available throughout.
Final verification: I grabbed the ALB DNS name and loaded it in the browser. No "development mode" banner — confirmed production mode. Refreshing showed different server IPs alternating, proving both instances were serving traffic behind the load balancer. ✅
Step 6 — Intentional Failure and Automatic Rollback
This is where it gets interesting. I simulated a bad deployment by pushing v1.1 — a version where someone accidentally changed the server's listening port from 8080 to 80.
cp -R commits/v1_1/. app-repo/
cd app-repo
git add -A
git commit -m "app v1.1"
git push
The pipeline triggered automatically. CodeBuild passed (the unit tests didn't catch a port misconfiguration — a realistic scenario). The deployment reached the ValidateService hook, which tried to curl localhost:8080... and got a connection refused.
What happened next, automatically:
-
ValidateServicescript failed → deployment failed on instance 1 - CodeDeploy's
OneAtATimeconfig meant instance 2 was skipped — it never received the broken version - CodeDeploy triggered an automatic rollback — a new deployment was initiated using the last successful revision
- The rollback deployment showed
Initiating event: codeDeployRollbackin the deployment history
The app was never fully broken in production. Only one instance briefly served the broken version, and it was rolled back before any real user impact. This is exactly why the ValidateService hook exists — your automated unit tests can't catch everything, but a post-deploy smoke test can.
Step 7 — Blue/Green Deployment
Finally, I switched the pipeline to use the blue-green deployment group and pushed v1.2 — a legitimate new feature (message emphasis toggles in the accumulator app).
cp -R commits/v1_2/. app-repo/
cd app-repo
git add -A
git commit -m "app v1.2"
git push
The blue/green deployment flow:
- New (green) instances provisioned — CodeDeploy creates fresh EC2 instances from the ASG configuration
-
App installed on green instances —
v1.2deployed and validated on the new instances -
Traffic rerouted — ALB gradually shifts traffic from blue (original) to green (new),
OneAtATime - Original (blue) instances retained — kept running for rollback capability
Watching the CodeDeploy console during this was genuinely satisfying: you could see the replacement instances appear, the lifecycle events complete, and traffic start routing to them — while the original instances continued serving users without interruption.
Final verification: Refreshed the app. New feature (emphasis toggles) was visible. Server IPs in the bottom corner were different from before — confirming entirely new instances were serving traffic, not the same ones from the in-place deployment. That's immutable infrastructure in action.
Key Takeaways
| Concept | What You Learned |
|---|---|
buildspec.yml |
Defines CodeBuild phases: install → pre_build → build → artifacts |
appspec.yml |
Defines CodeDeploy lifecycle hooks: stop → start → validate |
| In-place deployment | Rolling update on existing instances; faster but risks config drift |
| Blue/green deployment | New instances every time; zero-downtime cutover; immutable infra |
| Automatic rollback |
ValidateService hook + rollback config = self-healing pipeline |
Architecture Diagram
Developer (EC2 dev-instance)
│
│ git push
▼
SCM (Gitea)
│
│ webhook → uploads source.zip
▼
Amazon S3 (source bucket)
│
│ triggers CodePipeline
▼
AWS CodePipeline
┌────┴────────────────────────────────┐
│ │
[Source] [Build] [Production]
S3 → CodeBuild → CodeDeploy
(build + test) (in-place or
artifact → S3 blue/green)
│
┌───────┴───────┐
│ │
Instance 1 Instance 2
(EC2, prod) (EC2, prod)
└───────┬───────┘
│
Application Load Balancer
│
Users
What's Next
This pipeline covers the core CI/CD loop. From here, you could extend it with:
- Manual approval stage in CodePipeline before production (for regulated environments)
- SNS notifications on pipeline success/failure
- CloudWatch alarms tied to CodeDeploy to trigger rollbacks on metrics (not just script failures)
- EventBridge rules for instant pipeline triggers instead of S3 polling
- Parameter Store / Secrets Manager integration in the buildspec for managing environment variables securely
Built as part of the AWS CI/CD hands-on lab. Published under the AWS Builders community.
Tags: #aws #devops #cicd #codepipeline #codedeploy #codebuild #cloud #awscommunity










Top comments (0)