← Writing

How I ship to production: local → GitHub → Google Cloud VM, zero downtime

My actual deployment workflow — GitHub Actions, Docker, SSH into a Google Cloud Compute Engine VM, nginx, and a healthcheck loop that means I never have to manually touch the virtual server again.

illustrates.dev·24 May 2026·46 views
devopsdockergithub-actionsgoogle-clouddeployment

The problem with manual deploys

Every project I've shipped professionally started the same way.

Write code locally. SSH into the virtual server. Pull the repo. Restart the process. Hope nothing breaks. Repeat 40 times across a six-month engagement.

That works until it doesn't. You fat-finger a command at 11pm. You deploy to the wrong environment. You spend twenty minutes wondering why the virtual server is serving stale code when the issue is that you forgot to rebuild the Docker image.

This is the workflow I now use on every client project — built around GitHub Actions and a Google Cloud Compute Engine VM. It took me two evenings to set up properly and has saved me roughly an hour per week since.

The stack

  • Local machine → push to GitHub
  • GitHub Actions → build, test, push Docker image to registry
  • Google Cloud VM → pull new image, zero-downtime container swap
  • nginx → reverse proxy, handles TLS, routes traffic

No Jenkins. No Kubernetes. No managed platform fees beyond the Compute Engine instance.

Step 1 — Dockerfile that actually works in production

Most Dockerfiles I've inherited run the dev server in production. The image is 1.2GB. Cold starts take 40 seconds.

Here's the multi-stage build I use for a Next.js frontend:

dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs \
 && adduser --system --uid 1001 nextjs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Final image: ~180MB. The previous one was 1.4GB. That's not a rounding error — that's a different deployment philosophy.

Step 2 — GitHub Actions pipeline

The workflow triggers on every push to `main`. Three stages: build, push, deploy.

yaml
name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to Google Cloud VM
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.GCP_HOST }}
          username: ${{ secrets.GCP_USER }}
          key: ${{ secrets.GCP_SSH_KEY }}
          script: |
            cd ~/your-project
            git pull origin main
            docker-compose up -d --build

Four secrets live in GitHub → Settings → Secrets: `GCP_HOST`, `GCP_USER`, `GCP_SSH_KEY`, `DOCKER_TOKEN`.

The `.env` file on the virtual server never leaves it. Secrets don't pass through GitHub.

Step 3 — Zero downtime swap

The script above has a gap. Between docker stop and docker run the service is down for two to four seconds. Fine for a personal project. Not acceptable for a client.

The fix is a healthcheck loop before nginx flips to the new container:

bash
#!/bin/bash
IMAGE="yourusername/yourapp:latest"
docker pull $IMAGE
docker run -d --name app_new -p 3001:3000 --env-file /etc/app/.env --restart unless-stopped $IMAGE
for i in $(seq 1 20); do
  if curl -sf http://localhost:3001/api/health; then break; fi
  sleep 2
done
sed -i 's/localhost:3000/localhost:3001/' /etc/nginx/sites-enabled/app.conf
nginx -s reload
docker stop app && docker rm app
docker rename app_new app
docker image prune -f

If the new container never becomes healthy, the old one keeps serving and the GitHub Actions job fails — which notifies me immediately.

Step 4 — Google Cloud VM setup

Firewall rules. By default GCP blocks all inbound traffic. Create rules in the Google Cloud Console under VPC Network → Firewall allowing ports 80, 443, and SSH.

Static external IP. The external IP is ephemeral by default — it changes on reboot. Promote it to static under VPC Network → IP addresses or your DNS breaks every restart.

SSH key setup. Generate a dedicated deploy key pair. Add the public key to the VM's `~/.ssh/authorized_keys`. Put the private key in GitHub secrets as `GCP_SSH_KEY`.

nginx + Certbot. TLS is handled by Certbot and renews automatically via a systemd timer:

nginx
server {
    listen 443 ssl http2;
    server_name yourproject.com;
    ssl_certificate /etc/letsencrypt/live/yourproject.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourproject.com/privkey.pem;
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
    }
}
server {
    listen 80;
    server_name yourproject.com;
    return 301 https://$host$request_uri;
}

What the client sees

A staging environment always current with `main`. A production environment one merged PR away from updating. A deploy log in the GitHub Actions tab they can check themselves without waiting for me.

I've stopped having the "is the new version deployed?" conversation. The Actions tab answers it in green or red.

What this actually costs

GitHub's free tier covers 2,000 Actions minutes per month — enough for most client engagements. A Compute Engine `e2-small` instance in Europe costs around $13/month. Certbot is free. Docker Hub's free tier covers the image registry.

If the project outgrows the single VM the upgrade path is clear: a load-balanced instance group, Cloud SQL, Cloud Run for stateless services. The pipeline doesn't change.

Takeaway

The goal of a deployment pipeline isn't sophistication. It's removing yourself from the critical path.

When I push to `main` I want to forget about the deploy. If it fails I want to know within three minutes and I want the old version still running. Everything else is yak shaving.

This setup achieves that. It took me two evenings to template properly. Every project I deliver now gets it on day one.

If you're working on something that needs a similar setup — you know where to find me.

0 comments

Sign in to join the discussion