GitHub Actions CI/CD for NestJS: Deploy to EC2 with Docker (Free)
- CI/CD Pipelines for Side Projects: A Step-by-Step Guide
- What We're Building
- Step 1 — Dockerize Your NestJS App
- Step 2 — Set Up EC2 Server
- Step 3 — Configure GitHub Secrets
- Step 4 — Create GitHub Actions Workflow
- Step 5 — Add Nginx Config
- Step 6 — Database Migrations
- Step 7 — Environment-Specific Workflows
- What the Pipeline Looks Like
- Common Issues & Fixes
- Cost Breakdown
- What You Now Have
Most developers deploy side projects manually — SSH in, pull code, restart, hope nothing breaks. Here's how to automate the entire process with GitHub Actions and deploy to EC2 on every push to main.
CI/CD Pipelines for Side Projects: A Step-by-Step Guide
Most developers deploy their side projects manually — SSH into the server, pull the latest code, restart the process, and hope nothing breaks.
That works until it doesn't.
This guide shows you how to build a complete CI/CD pipeline using GitHub Actions that automatically tests, builds, and deploys your NestJS app to AWS EC2 on every push to main. No more manual deployments. No more 2am hotfixes gone wrong.
What We're Building
Developer pushes to main branch
↓
GitHub Actions triggers workflow
↓
Run tests → Build Docker image → Push to registry
↓
SSH into EC2 → Pull new image → Restart container
↓
App live with zero downtime ✅
Stack:
- NestJS + PostgreSQL application
- Docker for containerization
- GitHub Actions for CI/CD
- AWS EC2 for hosting
- GitHub Container Registry (free) for images
Step 1 — Dockerize Your NestJS App
Before automating deployments, your app needs to run in a container.
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Production image
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "dist/main.js"]
# docker-compose.yml — for local development
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: ${DATABASE_URL}
JWT_SECRET: ${JWT_SECRET}
REDIS_HOST: redis
depends_on:
- postgres
- redis
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
postgres_data:
Test it locally first:
docker build -t myapp .
docker run -p 3000:3000 myapp
Step 2 — Set Up EC2 Server
Launch a Ubuntu 22.04 EC2 instance (t2.micro for free tier).
Install Docker on EC2:
# SSH into your EC2
ssh -i your-key.pem ubuntu@your-ec2-ip
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Add ubuntu user to docker group
sudo usermod -aG docker ubuntu
# Install Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
# Verify
docker --version
docker-compose --version
Create deployment directory:
mkdir -p /home/ubuntu/app
cd /home/ubuntu/app
# Create production docker-compose
cat > docker-compose.prod.yml << 'EOF'
version: '3.8'
services:
app:
image: ghcr.io/YOUR_GITHUB_USERNAME/YOUR_REPO:latest
ports:
- "3000:3000"
environment:
DATABASE_URL: ${DATABASE_URL}
JWT_SECRET: ${JWT_SECRET}
REDIS_HOST: redis
NODE_ENV: production
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:7-alpine
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- app
restart: unless-stopped
EOF
Create .env on EC2:
cat > /home/ubuntu/app/.env << 'EOF'
DATABASE_URL=postgresql://user:password@your-db-host:5432/dbname
JWT_SECRET=your-super-secret-jwt-key
REDIS_HOST=redis
NODE_ENV=production
EOF
chmod 600 /home/ubuntu/app/.env
Step 3 — Configure GitHub Secrets
Go to your GitHub repo → Settings → Secrets and variables → Actions → New repository secret.
Add these secrets:
EC2_HOST → your EC2 public IP (e.g. 13.234.xxx.xxx)
EC2_USERNAME → ubuntu
EC2_SSH_KEY → your entire .pem file contents
GHCR_TOKEN → GitHub personal access token with packages:write scope
Generate GHCR token:
GitHub → Settings → Developer settings
→ Personal access tokens → Tokens (classic)
→ Generate new token
→ Select: write:packages, read:packages, delete:packages
→ Copy the token → paste as GHCR_TOKEN secret
Building something like this? Hire me to build your backend — NestJS, Node.js, PostgreSQL & Redis, shipped production-ready.
Step 4 — Create GitHub Actions Workflow
# .github/workflows/deploy.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ─────────────────────────────────────
# JOB 1: Run Tests
# ─────────────────────────────────────
test:
name: Run Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run unit tests
run: npm run test
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
JWT_SECRET: test-secret
- name: Run e2e tests
run: npm run test:e2e
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
JWT_SECRET: test-secret
# ─────────────────────────────────────
# JOB 2: Build & Push Docker Image
# ─────────────────────────────────────
build:
name: Build & Push Image
runs-on: ubuntu-latest
needs: test # only runs if tests pass
if: github.ref == 'refs/heads/main' # only on main branch
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ─────────────────────────────────────
# JOB 3: Deploy to EC2
# ─────────────────────────────────────
deploy:
name: Deploy to EC2
runs-on: ubuntu-latest
needs: build # only runs after image is built
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to EC2
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
cd /home/ubuntu/app
# Login to GitHub Container Registry
echo ${{ secrets.GHCR_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
# Pull latest image
docker pull ghcr.io/${{ github.repository }}:latest
# Stop and remove old container
docker-compose -f docker-compose.prod.yml down
# Start new container
docker-compose -f docker-compose.prod.yml up -d
# Remove unused images to save disk space
docker image prune -f
echo "✅ Deployment complete"
Step 5 — Add Nginx Config
# nginx.conf
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
}
Step 6 — Database Migrations
Never run migrations inside the app startup. Run them as a separate step in the pipeline.
# Add this step to your deploy job BEFORE starting containers
- name: Run database migrations
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
cd /home/ubuntu/app
# Run migrations in a one-off container
docker run --rm \
--env-file .env \
ghcr.io/${{ github.repository }}:latest \
npx prisma migrate deploy
echo "✅ Migrations complete"
// main.ts — do NOT run migrations on startup
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// ❌ Never do this in production
// await prisma.$executeRaw`...migration...`
await app.listen(3000);
}
Step 7 — Environment-Specific Workflows
For side projects, two environments is enough:
# Staging — deploys on push to develop branch
on:
push:
branches:
- develop # → staging server
- main # → production server
jobs:
deploy:
steps:
- name: Set environment
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "EC2_HOST=${{ secrets.PROD_EC2_HOST }}" >> $GITHUB_ENV
else
echo "EC2_HOST=${{ secrets.STAGING_EC2_HOST }}" >> $GITHUB_ENV
fi
What the Pipeline Looks Like
After pushing to main, go to your repo → Actions tab:
✅ Run Tests → 45 seconds
✅ Build & Push Image → 2 minutes 10 seconds
✅ Deploy to EC2 → 35 seconds
─────────────────────────────────────
Total → ~3 minutes 30 seconds
Every push to main deploys automatically. Every PR runs tests before merging.
Common Issues & Fixes
Permission denied on EC2:
# On EC2
sudo chmod 666 /var/run/docker.sock
# or add ubuntu to docker group and re-login
sudo usermod -aG docker ubuntu
newgrp docker
Old images filling up disk:
# Add to deploy script
docker system prune -f --volumes
Workflow not triggering:
# Check branch name matches exactly
on:
push:
branches: [main] # not "master"
Secrets not found:
Check: repo Settings → Secrets → Actions
Secrets are case-sensitive — EC2_HOST not ec2_host
Cost Breakdown
Running this entire setup:
| Service | Cost |
|---|---|
| EC2 t2.micro | Free tier (750hrs/month) |
| GitHub Actions | Free (2,000 min/month) |
| GitHub Container Registry | Free (500MB storage) |
| Total | $0/month |
For a side project, this is completely free.
What You Now Have
✅ Automated testing on every push
✅ Docker image built and versioned automatically
✅ Zero-touch deployment to EC2
✅ Database migrations run safely before deploy
✅ Nginx reverse proxy with easy SSL setup
✅ Old images cleaned up automatically
✅ Staging + production environments
✅ Full audit trail of every deployment in GitHub
No more SSH + git pull + pm2 restart. Push to main and walk away.
This CI/CD setup is what I use across my freelance projects. Combined with the NestJS + Prisma + PostgreSQL stack and Redis caching, it gives you a production-grade backend that deploys itself.
Need help setting this up for your project? Let's talk.
Have questions about the pipeline or stuck on a specific step? Drop a Message