devuplabs.cloud
PreviewLab53 min

GitHub Actions Deploy

Build Docker images, push to ECR, and deploy to ECS with GitHub Actions, from git push to rolling deployment.

Mental model

Think of this pipeline as a factory conveyor belt. GitHub is the raw material. Docker is the mold. ECR is the warehouse. ECS is the factory floor. ALB is the shipping dock. CloudWatch is the quality inspector.

Prerequisites

0 of 7 checked

The Application + Docker Image

Goal

Build a minimal Node.js app. Containerize it. Push it to ECR manually. Understand what the CI/CD pipeline will automate later.

Estimated time: 45 min – 1 hour


Create the sample app

What's happening here

Before automating deployment, you need something to deploy. We're building a minimal Express app with a /health endpoint, this endpoint is what ECS's ALB health checks will probe. If it doesn't return 200 OK: the task never receives traffic. Every ECS-deployed app needs one. We also expose / and /version so we can visually confirm which version of the code is running after each deployment.

bash
mkdir deploy-lab && cd deploy-lab
npm init -y
npm install express
javascript
cat > server.js << 'EOF'
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
const VERSION = process.env.APP_VERSION || 'v1.0.0';

app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() }));
app.get('/version', (req, res) => res.json({ version: VERSION }));
app.get('/', (req, res) => res.send(`Hello from ${VERSION}!`));

app.listen(PORT, () => console.log(`Server running on port ${PORT}, version ${VERSION}`));
EOF

Test locally:

bash
node server.js &
curl localhost:3000/health
curl localhost:3000/version
kill %1

Checkpoint: /health returns {"status":"ok", ...} and /version returns {"version":"v1.0.0"}.


Write the Dockerfile

What's happening here

A Dockerfile is a recipe for an immutable artifact: the Docker image. Once built, the image is identical whether it runs on your laptop, in GitHub Actions, or on ECS. This is the core value of containerization: eliminate environment drift. Key decisions:

  • node:18-alpine: small base image (~50MB vs ~900MB for node:18). Smaller = faster push/pull, smaller attack surface.
  • npm ci --only=production: reproducible install from package-lock.json. npm install is non-deterministic across environments.
  • Copy package*.json before source code. Docker layer caching. If your code changes but dependencies don't, Docker reuses the cached npm ci layer. Build time: 30s → 2s for code-only changes.
  • EXPOSE 3000: metadata only. Doesn't open the port; that's done in the ECS task definition.
docker
cat > Dockerfile << 'EOF'
FROM node:18-alpine

WORKDIR /app

# Copy deps first -- cache this layer
COPY package*.json ./
RUN npm ci --only=production

# Copy source after deps
COPY . .

EXPOSE 3000
CMD ["node", "server.js"]
EOF
bash
cat > .dockerignore << 'EOF'
node_modules
.git
*.md
.env
EOF

Build and test locally:

bash
docker build -t deploy-lab:local .
docker run -d -p 3000:3000 --name deploy-lab-test deploy-lab:local
curl localhost:3000/health
curl localhost:3000/version
docker stop deploy-lab-test && docker rm deploy-lab-test

Checkpoint: container runs locally, both endpoints respond correctly.


Create ECR repository and push manually

What's happening here

ECR is AWS's private Docker registry. Docker Hub, but inside your AWS account. ECS pulls images from ECR when launching tasks. Image tag strategy:

  • latest: mutable, always points to the newest image. Dangerous in production (you can't tell what "latest" actually is).
  • git commit SHA: immutable, ties the image to exact source code. This is what GitHub Actions will use: image:$GITHUB_SHA.

Pushing manually now mirrors exactly what GitHub Actions will automate later.

bash
export ECR_REPO_URI=$(aws ecr create-repository \
  --repository-name deploy-lab \
  --region $AWS_REGION \
  --query 'repository.repositoryUri' --output text)

echo "ECR URI: $ECR_REPO_URI"

# Authenticate Docker to ECR
aws ecr get-login-password --region $AWS_REGION | \
  docker login --username AWS --password-stdin $ECR_REGISTRY

# Tag and push
docker tag deploy-lab:local $ECR_REPO_URI:v1.0.0
docker tag deploy-lab:local $ECR_REPO_URI:latest
docker push $ECR_REPO_URI:v1.0.0
docker push $ECR_REPO_URI:latest

# Verify
aws ecr list-images \
  --repository-name deploy-lab \
  --region $AWS_REGION \
  --query 'imageIds[*].{Tag:imageTag}'

Checkpoint: two tags visible in ECR, v1.0.0 and latest.


Break It: The Application + Docker Image

Break 1: Missing .dockerignore, node_modules bloat

bash
rm .dockerignore
docker build -t deploy-lab:bloated .

# Compare sizes
docker inspect deploy-lab:bloated --format '{{.Size}}' | numfmt --to=iec
docker inspect deploy-lab:local --format '{{.Size}}' | numfmt --to=iec

# Restore
cat > .dockerignore << 'EOF'
node_modules
.git
*.md
.env
EOF

Observe: without .dockerignore, node_modules is copied in even though npm ci reinstalls them. Two copies. Image size nearly doubles.

Break 2: Push without full registry URI

bash
docker push deploy-lab:local 2>&1 || echo "Failed as expected"

Observe: Docker tries to push to Docker Hub, not ECR. You must always use the full $ECR_REPO_URI:tag format.


ECS Infrastructure Setup

Goal

Create the ECS cluster, task definition, ALB, and service. Manually launch the container. Understand each component before automating deployment.

Unlock all 24 AWS services & 291+ lab sessions (~180 hours)

Pricing