Back to Articles

From git pull to GitOps: How I Built a Production CI/CD Pipeline on a $12 DigitalOcean Droplet

Published February 10, 2026
githubactionscicddevopsdockerdigitaloceandropletvps

From 15-minute manual deploys with downtime to 5-minute automated pipelines with 2-second container swaps: how I transformed my portfolio's deployment workflow using GitHub Actions, GHCR, and Docker Compose.

"If deploying scares you, you're not deploying often enough."


⚠️ The Problem:

Manual Deploys Don't Scale

My portfolio (luisfaria.dev) runs a full-stack application on a single DigitalOcean droplet. The stack is real — not a static site, but a living MERN application with authentication, a chatbot, rate limiting, and a GraphQL API.

ComponentTechnology
InfrastructureUbuntu 24.10 droplet (2GB RAM, 1 vCPU, 70GB disk)
OrchestrationDocker Compose (5 containers)
FrontendNext.js 16 (standalone mode)
BackendNode.js + Express + Apollo Server + GraphQL
DatabaseMongoDB 4.4
CacheRedis
Reverse ProxyNGINX with SSL (Let's Encrypt)

Every time I wanted to ship a change, here's what I did:

# The "old way" — every single time ssh root@my-server cd /var/www/portfolio git pull origin master docker compose down # Site goes DOWN docker compose build # 10+ minutes on 1 vCPU docker compose up -d # Pray it works docker compose logs # Check for errors

The pain points were real:

  • 10+ minutes of downtime per deploy (building Node.js/Next.js on a 1 vCPU machine)
  • No automated tests — I could push broken code directly to production
  • No rollback — if something broke, I'd manually git revert and rebuild
  • Fear of pushing — every deploy was a gamble

The Goal

Turn this into a one-step process:

git push origin master → ✅ Tests → 📦 Build → 🚀 Deploy → 🔔 Discord ping

With automated rollback if anything goes wrong.


🏛️ The Architecture:

GitHub Actions → GHCR → DigitalOcean

Here's the pipeline I designed:

Continuous Integration Pipeline - Mermaid Diagram
Continuous Integration Pipeline - Mermaid Diagram

The key insight: Don't build on the server. Build in GitHub Actions (free runners with 7GB RAM), push to GHCR, and just pull on the VPS.


📝 The Journey:

20+ Iterations, 8 Bugs, 1 Working Pipeline

This didn't work on the first try. Or the fifth. Here's the honest changelog — every failure and its fix.

Phase 1: Foundation (Issues 1-3)

GitHub Actions + Docker Registry + SSH Access

Setting up the basics: a CI workflow that runs Jest tests in parallel, builds Docker images, and pushes them to GitHub Container Registry.

# .github/workflows/ci.yml (simplified) jobs: backend-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test -- --coverage frontend-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test -- --coverage docker-build: needs: [backend-test, frontend-test] steps: - uses: docker/build-push-action@v5 with: push: true tags: ghcr.io/lfariabr/luisfaria.dev/frontend:latest

For secure server access, I created a dedicated deploy user with Docker permissions and ED25519 SSH keys stored in GitHub Secrets. No root access, no passwords — just key-based auth.


Phase 2: Deployment + Rollback (Issues 4-5)

The deploy step SSHs into the server, pulls the latest images, and restarts containers:

deploy: needs: [docker-build] steps: - uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_KEY }} script: | cd /var/www/portfolio # Save rollback point git rev-parse HEAD > /var/lib/deploy-rollback/commit.txt # Pull pre-built images (FAST!) docker compose pull # Swap containers (~2 seconds) docker compose up -d --force-recreate --remove-orphans

Automated rollback saves the current commit SHA before each deploy. If health checks fail, the pipeline automatically reverts:

# Auto-rollback on failure PREV_COMMIT=$(cat /var/lib/deploy-rollback/commit.txt) git reset --hard $PREV_COMMIT docker compose up -d --force-recreate --remove-orphans

Phase 3: The Hard Part — 8 Bugs in 11 Iterations

This is where things got real. Here's every failure I hit:

#ErrorRoot CauseFix
1ssh: unable to authenticateWrong format for DEPLOY_KEY secretPasted full private key content (not fingerprint)
2dubious ownership in repositoryDeploy user ≠ repo ownergit config --global --add safe.directory
3Permission denied .git/FETCH_HEADFile ownership mismatchchown -R deploy:deploy /var/www/portfolio
4local changes would be overwrittenServer had uncommitted driftSwitched from git pull to git reset --hard
5Deploy timeout (CPU maxed)Building images on a $12 dropletStopped building on server — pull from GHCR instead
6502 Bad GatewayFrontend container crashed + NGINX stale DNS--force-recreate + nginx -s reload
7Container name conflictDead container blocking recreationAdded --force-recreate flag
8Cannot find module @apollo/server/express4Apollo Server v5 breaking changeInstalled @as-integrations/express4

Bug #5 was the turning point. I was building Docker images on the server — a 1 vCPU machine trying to compile Next.js and Node.js simultaneously. It would timeout after 10 minutes, CPU pegged at 100%.

The fix was embarrassingly obvious: I was already building images in GitHub Actions. Just use them!

# docker-compose.yml — BEFORE (slow, broke the server) webapp: build: ./frontend # docker-compose.yml — AFTER (fast, reliable) webapp: image: ghcr.io/lfariabr/luisfaria.dev/frontend:latest

Bug #6 was the sneakiest. After deploying new images, the site returned 502 Bad Gateway. The frontend container was running and responding on port 3000. But NGINX couldn't reach it. Why?

Docker Compose assigns internal IPs to containers. When --force-recreate destroys and recreates a container, it gets a new IP. NGINX had cached the old IP. The fix: reload NGINX after container recreation.


🏆 The Unexpected Hero: TDD

Here's a story I didn't expect to tell: After the pipeline was working, I made a simple change — added "2026" to my portfolio's timeline section. Pushed to master. The CI pipeline kicked in... and blocked the deploy.

Why? My Jest tests validated the timeline data, and "2026" wasn't in the expected values.

FAIL src/__tests__/components/sections/TimelineSection.test.tsx ✕ should render timeline years correctly

I fixed the test, pushed again, and the deploy went through automatically. The pipeline caught a bug that would have been invisible in a manual workflow.

TDD doesn't just catch logic errors — it catches deployment errors too.


📊 Result

MetricBeforeAfter
Deploy time15-20 min (manual SSH + build)~5 min (automated end-to-end)
Downtime10+ min (docker build on server)~2 seconds (container swap)
RollbackManual git revert + rebuildAutomatic on health check failure
Test coverageNone before deployFull Jest suite (backend + frontend)
NotificationsCheck server logs manuallyDiscord ping on success/failure
ConfidenceAfraid to push on FridayPush anytime, pipeline has my back

Pipeline Stats

  • Total CI time: ~5 minutes (tests → build → push → deploy)
  • Container swap downtime: ~2 seconds
  • Image pull time: ~15 seconds (vs 10+ min for docker build)
  • Reliability: 100% after hardening (11 iterations)

📌 Key Takeaways

Five lessons from building CI/CD on a budget:

1. Don't Build on Small VPS

Offload compilation to CI runners. GitHub Actions gives you 7GB RAM and 2 vCPUs for free. Your $12 droplet should only pull and run.

2. TDD Is Your Deployment Safety Net

Tests caught bugs I would have shipped to production. The pipeline won't deploy what doesn't pass — and that's the point.

3. Force-Recreate Everything

Stale containers cause mysterious failures. Always use docker compose up -d --force-recreate in CI. The 2-second overhead is worth the reliability.

4. Reload NGINX After Container Swaps

Docker DNS caches container IPs. After --force-recreate, NGINX still points to the old IP. Always nginx -s reload.

5. Fail Fast, Log Everything

Every one of those 8 bugs was diagnosed through logs. Verbose output in CI scripts is not noise — it's your debugging lifeline.


Tech Stack

LayerTechnologyPurpose
CI/CDGitHub ActionsTest, build, deploy orchestration
RegistryGHCR (GitHub Container Registry)Docker image storage, tagged by SHA
FrontendNext.js 16 (standalone)SSR portfolio with React 19
BackendNode.js + Apollo Server 5 + GraphQLAPI with auth, rate limiting, chatbot
DatabaseMongoDB 4.4Document storage
CacheRedisRate limiting, session management
ProxyNGINX + Let's EncryptSSL termination, reverse proxy
InfraDigitalOcean DropletUbuntu 24.10, Docker Compose
NotificationsDiscord WebhooksDeploy success/failure alerts
TestingJestUnit + integration tests (backend + frontend)

Future Roadmap

While the current pipeline covers the essentials, there's room to grow:

Staging Environment

Branch-based deployments with a separate staging environment for pre-production testing. Currently deferred — the portfolio doesn't justify the cost of a second droplet.

Monitoring & Alerting

Sentry for error tracking, uptime monitoring, and resource alerts. Current health checks cover the basics, but production-grade observability is the next evolution.

Zero-Downtime Deploys

True zero-downtime with multi-replica services and rolling updates via Docker Swarm or a lightweight orchestrator. Current ~2s downtime is acceptable for a portfolio, but the architecture is ready for it.


Try It Yourself

The full CI/CD implementation is open source:

ResourceLink
Live Siteluisfaria.dev
Open Source Repohttps://github.com/lfariabr/luisfaria.dev
CI Workflow.github/workflows/ci.yml
Docker Composedocker-compose.yml
Epic TrackerIssue #107 — CI/CD Epic
All 20+ CI CommitsCommit history

Let's Connect!

Building this CI/CD pipeline was one of the most rewarding engineering challenges on my portfolio — 20+ iterations of debugging SSH keys, Docker DNS, NGINX caching, and package breaking changes. Every failure taught me something production engineers deal with daily.

If you're working with:

  • GitHub Actions and Docker-based deployments
  • DigitalOcean or similar VPS infrastructure
  • MERN/Next.js applications in production
  • CI/CD pipelines on a budget

I'd love to connect and trade war stories:


Tech Stack Summary:

Current ImplementationFuture Extensions
GitHub Actions, GHCR, Docker Compose, NGINX, Next.js, Node.js, MongoDB, Redis, Jest, Discord WebhooksStaging environment, Sentry, zero-downtime rolling updates, Kubernetes migration

Built with ☕ and a couple of failed deploys by Luis Faria

Whether it's concrete or code, structure is everything.