Merge pull request #10 from elizabeth-ikechukwu/feat/week4-cicd-pipeline

feat: add CI/CD pipeline with OIDC, ECR, Docker Hub, lint and tests
This commit is contained in:
Elizabeth Nkwo Ikechukwu
2026-04-19 19:31:26 +01:00
committed by GitHub
13 changed files with 6076 additions and 36 deletions
+244
View File
@@ -0,0 +1,244 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
AWS_REGION: us-east-1
ECR_REGISTRY: 424522917561.dkr.ecr.us-east-1.amazonaws.com
DOCKERHUB_REGISTRY: lizzycloudlab
permissions:
id-token: write
contents: read
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: app/backend/package-lock.json
- name: Install backend dependencies
working-directory: app/backend
run: npm ci
- name: Run ESLint
working-directory: app/backend
run: npx eslint . --ext .js --max-warnings=0
unit-test:
name: Unit Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: app/backend/package-lock.json
- name: Install backend dependencies
working-directory: app/backend
run: npm ci
- name: Run unit tests
working-directory: app/backend
run: npm test
build-and-integration-test:
name: Build & Integration Test
runs-on: ubuntu-latest
needs: unit-test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build backend image
uses: docker/build-push-action@v5
with:
context: ./app/backend
file: ./app/backend/Dockerfile
push: false
load: true
tags: task-backend:ci
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build frontend image
uses: docker/build-push-action@v5
with:
context: ./app/frontend
file: ./app/frontend/Dockerfile
push: false
load: true
tags: task-frontend:ci
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Start stack
run: docker compose up -d
- name: Wait for backend to be healthy
run: |
echo "Waiting for backend health..."
for i in $(seq 1 15); do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' task-backend 2>/dev/null || echo "not_found")
echo "Attempt $i: backend = $STATUS"
if [ "$STATUS" = "healthy" ]; then
echo "Backend is healthy."
break
fi
if [ "$i" = "15" ]; then
echo "Backend did not become healthy in time."
docker compose logs backend
exit 1
fi
sleep 5
done
- name: Wait for frontend to be healthy
run: |
echo "Waiting for frontend health..."
for i in $(seq 1 15); do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' task-frontend 2>/dev/null || echo "not_found")
echo "Attempt $i: frontend = $STATUS"
if [ "$STATUS" = "healthy" ]; then
echo "Frontend is healthy."
break
fi
if [ "$i" = "15" ]; then
echo "Frontend did not become healthy in time."
docker compose logs frontend
exit 1
fi
sleep 5
done
- name: Assert backend health endpoint
run: |
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/api/health)
echo "GET /api/health returned: $HTTP_STATUS"
if [ "$HTTP_STATUS" != "200" ]; then
echo "FAILED: expected 200, got $HTTP_STATUS"
docker compose logs
exit 1
fi
echo "PASSED"
- name: Assert frontend serves 200
run: |
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost)
echo "GET / returned: $HTTP_STATUS"
if [ "$HTTP_STATUS" != "200" ]; then
echo "FAILED: expected 200, got $HTTP_STATUS"
docker compose logs
exit 1
fi
echo "PASSED"
- name: Print logs on failure
if: failure()
run: docker compose logs
- name: Tear down stack
if: always()
run: docker compose down --volumes
publish:
name: Publish Images
runs-on: ubuntu-latest
needs: build-and-integration-test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract short SHA
id: vars
run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::424522917561:role/github-actions-scalable-task-platform
aws-region: us-east-1
- name: Log in to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: ./app/backend
file: ./app/backend/Dockerfile
push: true
tags: |
${{ env.ECR_REGISTRY }}/task-backend:latest
${{ env.ECR_REGISTRY }}/task-backend:sha-${{ steps.vars.outputs.sha }}
${{ env.DOCKERHUB_REGISTRY }}/task-backend:latest
${{ env.DOCKERHUB_REGISTRY }}/task-backend:sha-${{ steps.vars.outputs.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: ./app/frontend
file: ./app/frontend/Dockerfile
push: true
tags: |
${{ env.ECR_REGISTRY }}/task-frontend:latest
${{ env.ECR_REGISTRY }}/task-frontend:sha-${{ steps.vars.outputs.sha }}
${{ env.DOCKERHUB_REGISTRY }}/task-frontend:latest
${{ env.DOCKERHUB_REGISTRY }}/task-frontend:sha-${{ steps.vars.outputs.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Job summary
run: |
echo "### Published Images - $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**ECR**" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.ECR_REGISTRY }}/task-backend:latest\`" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.ECR_REGISTRY }}/task-backend:sha-${{ steps.vars.outputs.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.ECR_REGISTRY }}/task-frontend:latest\`" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.ECR_REGISTRY }}/task-frontend:sha-${{ steps.vars.outputs.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Docker Hub**" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.DOCKERHUB_REGISTRY }}/task-backend:latest\`" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.DOCKERHUB_REGISTRY }}/task-backend:sha-${{ steps.vars.outputs.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.DOCKERHUB_REGISTRY }}/task-frontend:latest\`" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.DOCKERHUB_REGISTRY }}/task-frontend:sha-${{ steps.vars.outputs.sha }}\`" >> $GITHUB_STEP_SUMMARY
+68 -21
View File
@@ -4,59 +4,77 @@ A production-grade DevOps project documenting the full lifecycle of a task manag
Built in public, one week at a time.
---
## What This Project Demonstrates
- Backend API development with Node.js and Express
- Containerization with Docker
- CI/CD automation with GitHub Actions
- CI/CD automation with GitHub Actions, Amazon ECR, and Docker Hub
- Infrastructure as Code with Terraform
- Cloud deployment on AWS
- Container orchestration with Kubernetes
- Monitoring and observability with Prometheus and Grafana
---
## Stack
| Layer | Technology |
|-------|-----------|
|---|---|
| Backend | Node.js, Express |
| Containerization | Docker, Docker Compose |
| CI/CD | GitHub Actions |
| Image Registry | Amazon ECR, Docker Hub |
| Infrastructure | Terraform |
| Cloud | AWS EC2 |
| Orchestration | Kubernetes |
| Monitoring | Prometheus, Grafana |
---
## Roadmap
| Week | Topic | Status |
|------|-------|--------|
| 1 | Project foundation and backend setup | [Done](docs/week-1-update.md) |
|---|---|---|
| 1 | Project foundation and backend API | [Done](docs/week-1-update.md) |
| 2 | Dockerize the backend | [Done](docs/week-2-update.md) |
| 3 | Add frontend and Docker Compose | [Done](docs/week-3-update.md) |
| 4 | CI with GitHub Actions | Upcoming |
| 5 | CD workflow and image registry | Upcoming |
| 6 | Terraform infrastructure setup | Upcoming |
| 7 | Cloud deployment on AWS | Upcoming |
| 3 | React frontend and Docker Compose | [Done](docs/week-3-update.md) |
| 4 | CI/CD pipeline with GitHub Actions, ECR and Docker Hub | [Done](docs/week-4-update.md) |
| 5 | Terraform infrastructure setup | Upcoming |
| 6 | Cloud deployment on AWS EC2 | Upcoming |
| 7 | Database integration | Upcoming |
| 8 | Kubernetes deployment | Upcoming |
| 9 | Monitoring with Prometheus and Grafana | Upcoming |
| 10 | Hardening and project polish | Upcoming |
---
## API Endpoints
| Method | Route | Description |
|--------|-------|-------------|
| GET | / | API health check |
| GET | /health | Returns status and uptime |
| GET | /tasks | Returns all tasks |
| POST | /tasks | Creates a new task |
| PATCH | /tasks/:id | Toggles task complete or incomplete |
| DELETE | /tasks/:id | Deletes a task |
|---|---|---|
| GET | /api | API status |
| GET | /api/health | Returns status and uptime |
| GET | /api/tasks | Returns all tasks |
| POST | /api/tasks | Creates a new task |
| PATCH | /api/tasks/:id | Toggles task complete or incomplete |
| DELETE | /api/tasks/:id | Deletes a task |
---
## Getting Started
### Prerequisites
- Node.js 18 or higher
- npm
### Run Locally
- Node.js 20 or higher
- npm
- Docker and Docker Compose
### Run Locally Without Docker
```bash
git clone https://github.com/YOUR_USERNAME/scalable-task-platform.git
git clone https://github.com/elizabeth-ikechukwu/scalable-task-platform.git
cd scalable-task-platform/app/backend
cp .env.example .env
npm install
@@ -65,13 +83,42 @@ npm start
Server runs on http://localhost:3000
### Run With Docker Compose
```bash
git clone https://github.com/elizabeth-ikechukwu/scalable-task-platform.git
cd scalable-task-platform
docker compose up -d
```
Frontend runs on http://localhost:80
---
## CI/CD Pipeline
Every pull request and push to `main` triggers a four-stage pipeline:
```
lint -> unit-test -> build-and-integration-test -> publish
```
The publish stage runs on merge to `main` only. Images are pushed to both Amazon ECR and Docker Hub, tagged with `latest` and the short Git SHA for traceability.
AWS authentication uses OIDC - no static credentials stored in GitHub secrets.
---
## Weekly Updates
Detailed documentation for each week is in the [docs](docs/) folder.
---
## Author
Ikechukwu Elizabeth Nkwo
DevOps and Cloud Engineer
[LinkedIn](https://www.linkedin.com/in/ikechukwu-elizabeth)
[LinkedIn](https://www.linkedin.com/in/ikechukwu-elizabeth)
+36
View File
@@ -0,0 +1,36 @@
const js = require("@eslint/js");
module.exports = [
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2021,
globals: {
require: "readonly",
module: "readonly",
exports: "readonly",
process: "readonly",
__dirname: "readonly",
console: "readonly",
},
},
rules: {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"no-console": "off",
},
},
{
files: ["**/*.test.js"],
languageOptions: {
globals: {
describe: "readonly",
it: "readonly",
expect: "readonly",
beforeEach: "readonly",
afterEach: "readonly",
beforeAll: "readonly",
afterAll: "readonly",
},
},
},
];
+5465
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -5,10 +5,18 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
"dev": "node --watch server.js",
"test": "jest --forceExit",
"lint": "eslint . --ext .js --max-warnings=0"
},
"dependencies": {
"dotenv": "^17.4.0",
"express": "^4.21.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.2.1",
"jest": "^30.3.0",
"supertest": "^7.2.2"
}
}
}
+19 -11
View File
@@ -11,19 +11,23 @@ let tasks = [];
let nextId = 1;
// Routes
app.get("/", (req, res) => {
app.get("/api", (req, res) => {
res.json({ message: "Task API is running", version: "1.0.0" });
});
app.get("/health", (req, res) => {
res.status(200).json({ status: "healthy", uptime: process.uptime() });
app.get("/api/health", (req, res) => {
res.status(200).json({
status: "ok",
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
});
app.get("/tasks", (req, res) => {
app.get("/api/tasks", (req, res) => {
res.json(tasks);
});
app.post("/tasks", (req, res) => {
app.post("/api/tasks", (req, res) => {
const { title } = req.body;
if (!title) {
return res.status(400).json({ error: "Title is required" });
@@ -33,14 +37,14 @@ app.post("/tasks", (req, res) => {
res.status(201).json(task);
});
app.patch("/tasks/:id", (req, res) => {
app.patch("/api/tasks/:id", (req, res) => {
const task = tasks.find((t) => t.id === parseInt(req.params.id));
if (!task) return res.status(404).json({ error: "Task not found" });
task.completed = !task.completed;
res.json(task);
});
app.delete("/tasks/:id", (req, res) => {
app.delete("/api/tasks/:id", (req, res) => {
const index = tasks.findIndex((t) => t.id === parseInt(req.params.id));
if (index === -1) return res.status(404).json({ error: "Task not found" });
tasks.splice(index, 1);
@@ -53,11 +57,15 @@ app.use((req, res) => {
});
// Global error handler
app.use((err, req, res, next) => {
app.use((err, req, res, _next) => {
console.error(err.stack);
res.status(500).json({ error: "Internal server error" });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
if (require.main === module) {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}
module.exports = app;
+34
View File
@@ -0,0 +1,34 @@
const request = require('supertest');
const app = require('./server');
describe('Health Check', () => {
it('GET /api/health returns 200 with status ok', async () => {
const res = await request(app).get('/api/health');
expect(res.statusCode).toBe(200);
expect(res.body.status).toBe('ok');
});
});
describe('Tasks API', () => {
it('GET /api/tasks returns empty array initially', async () => {
const res = await request(app).get('/api/tasks');
expect(res.statusCode).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('POST /api/tasks creates a new task', async () => {
const res = await request(app)
.post('/api/tasks')
.send({ title: 'Test task' });
expect(res.statusCode).toBe(201);
expect(res.body.title).toBe('Test task');
expect(res.body.completed).toBe(false);
});
it('POST /api/tasks returns 400 when title is missing', async () => {
const res = await request(app)
.post('/api/tasks')
.send({});
expect(res.statusCode).toBe(400);
});
});
+2
View File
@@ -14,6 +14,8 @@ RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
RUN apk add --no-cache wget
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
+1 -1
View File
@@ -8,7 +8,7 @@ server {
}
location /api/ {
proxy_pass http://backend:3000/;
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+14 -1
View File
@@ -11,6 +11,12 @@ services:
- PORT=3000
networks:
- task-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
frontend:
build:
@@ -21,9 +27,16 @@ services:
ports:
- "80:80"
depends_on:
- backend
backend:
condition: service_healthy
networks:
- task-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
networks:
task-network:
+135
View File
@@ -0,0 +1,135 @@
# Week 4 - GitHub Actions CI/CD Pipeline
## Overview
Week 4 introduces a production-grade CI/CD pipeline to the Scalable Task Platform. Every push and pull request targeting `main` runs a full four-stage pipeline: lint, unit tests, integration test, and image publish. AWS authentication uses OIDC - no static credentials stored anywhere. Images are pushed to both Amazon ECR and Docker Hub on every successful merge to `main`.
---
## Pipeline Architecture
```
Push / PR to main
|
v
[ lint ]
|
v
[ unit-test ]
needs: lint
|
v
[ build-and-integration-test ]
needs: unit-test
|
v
[ publish ]
needs: build-and-integration-test
if: push to main only, skipped on PRs
|
+---------------------------+
| |
v v
Amazon ECR Docker Hub
task-backend:latest lizzycloudlab/task-backend:latest
task-backend:sha-<sha> lizzycloudlab/task-backend:sha-<sha>
task-frontend:latest lizzycloudlab/task-frontend:latest
task-frontend:sha-<sha> lizzycloudlab/task-frontend:sha-<sha>
```
---
## Files Added / Modified
| File | Change |
|---|---|
| `.github/workflows/ci-cd.yml` | Single workflow replacing two separate files |
| `docker-compose.yml` | Added healthcheck blocks, upgraded depends_on to condition: service_healthy |
| `app/backend/server.js` | Prefixed all routes with /api/, updated health endpoint response |
| `iam/github-actions-trust-policy.json` | OIDC trust policy scoped to this repo only |
| `iam/github-actions-ecr-policy.json` | Least-privilege ECR push permissions |
---
## AWS Authentication: OIDC
This pipeline uses OpenID Connect (OIDC) for AWS authentication. No AWS access keys are stored in GitHub secrets.
Each workflow run, GitHub generates a short-lived signed JWT token. AWS STS verifies the token against the registered GitHub OIDC provider and issues temporary credentials scoped to that job only. Credentials expire the moment the job finishes. Nothing to rotate, nothing to leak.
The IAM role `github-actions-scalable-task-platform` is scoped tightly:
- **Trust policy** - only tokens from `repo:elizabeth-ikechukwu/scalable-task-platform:*` can assume the role
- **Permissions policy** - ECR push to `task-backend` and `task-frontend` only, nothing else in the AWS account
---
## Image Tagging Strategy
| Tag | Purpose |
|---|---|
| `latest` | Always points to the most recent successful build |
| `sha-abc1234` | Immutable reference to the exact commit, used for rollback |
Every running container can be traced back to the exact line of code that produced it using the SHA tag.
---
## ECR Repositories
| Repository | URI |
|---|---|
| task-backend | `424522917561.dkr.ecr.us-east-1.amazonaws.com/task-backend` |
| task-frontend | `424522917561.dkr.ecr.us-east-1.amazonaws.com/task-frontend` |
Both repositories configured with: private visibility, mutable tags, AES-256 encryption, scan on push enabled.
---
## GitHub Secrets
| Secret | Purpose |
|---|---|
| `DOCKERHUB_USERNAME` | Docker Hub login username |
| `DOCKERHUB_TOKEN` | Docker Hub access token (not password) |
No AWS secrets needed. OIDC handles AWS authentication entirely.
---
## Docker Compose Health Checks
Both services now have health checks defined. Docker probes the backend every 10 seconds via `GET /api/health`. The frontend `depends_on` uses `condition: service_healthy` meaning Nginx will not start until the backend passes its health probe. This prevents the frontend from coming up pointing at a backend that is not ready.
---
## Branch Protection
`main` is protected with the following rules enforced:
- No direct pushes - all changes must go through a pull request
- Force pushes blocked
- Branch deletion blocked
All development happens on feature branches and merges to `main` via PR.
---
## Testing and Linting
The backend includes a full test and lint setup before the pipeline was built.
**ESLint** - configured via `eslint.config.js` using the new flat config format
required by ESLint v9+. Lints all `.js` files with a separate config block for
test files that includes Jest globals.
**Jest + Supertest** - four unit and integration tests covering:
- `GET /api/health` returns 200 with status ok
- `GET /api/tasks` returns empty array initially
- `POST /api/tasks` creates a task correctly
- `POST /api/tasks` returns 400 when title is missing
Both pass cleanly locally before the pipeline runs them in CI.
## What Week 5 Adds
Week 5 introduces Terraform to provision AWS infrastructure - VPC, subnets, security groups, and EC2 instance. The EC2 instance will pull images directly from ECR at deploy time using the same OIDC authentication pattern established this week.
+27
View File
@@ -0,0 +1,27 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ECRAuthentication",
"Effect": "Allow",
"Action": "ecr:GetAuthorizationToken",
"Resource": "*"
},
{
"Sid": "ECRImagePush",
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:CompleteLayerUpload",
"ecr:DescribeRepositories",
"ecr:InitiateLayerUpload",
"ecr:PutImage",
"ecr:UploadLayerPart"
],
"Resource": [
"arn:aws:ecr:us-east-1:424522917561:repository/task-backend",
"arn:aws:ecr:us-east-1:424522917561:repository/task-frontend"
]
}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GitHubActionsOIDC",
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::424522917561:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:elizabeth-ikechukwu/scalable-task-platform:*"
}
}
}
]
}