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
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.
mkdir deploy-lab && cd deploy-lab
npm init -y
npm install expresscat > 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}`));
EOFTest locally:
node server.js &
curl localhost:3000/health
curl localhost:3000/version
kill %1Checkpoint: /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 frompackage-lock.json.npm installis non-deterministic across environments.- Copy
package*.jsonbefore source code. Docker layer caching. If your code changes but dependencies don't, Docker reuses the cachednpm cilayer. 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.
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"]
EOFcat > .dockerignore << 'EOF'
node_modules
.git
*.md
.env
EOFBuild and test locally:
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-testCheckpoint: 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.
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
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
EOFObserve: 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
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.