DevOps used to mean a separate team with arcane knowledge. In 2026, it's a skill every full-stack developer needs. This guide covers the essentials: Docker, GitHub Actions, and deploying a Next.js app to a VPS — all from first principles.
1. Why Docker?
Docker packages your application and its runtime into an immutable image. That image runs identically on your laptop, in CI, and on the production server. No more "works on my machine."
Key concepts
- Image — a read-only snapshot (layers of file system changes)
- Container — a running instance of an image
- Dockerfile — the recipe that builds an image
- Registry — where images live (Docker Hub, GitHub Container Registry)
2. Dockerfile for Next.js
Next.js's standalone output mode produces a minimal Node server — perfect for Docker.
# ── Stage 1: deps ───────────────────────────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ── Stage 2: builder ─────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ── Stage 3: runner (production) ────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Only copy what Next.js standalone needs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]Enable standalone in next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
module.exports = nextConfig;Build and run locally:
docker build -t my-nextjs-app .
docker run -p 3000:3000 my-nextjs-app3. Docker Compose for Local Development
Use Compose to spin up your app alongside a database:
# docker-compose.yml
version: "3.9"
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=mongodb://mongo:27017/mydb
depends_on:
- mongo
mongo:
image: mongo:7
volumes:
- mongo_data:/data/db
ports:
- "27017:27017"
volumes:
mongo_data:docker compose up -d # start in background
docker compose logs -f # stream logs
docker compose down # stop and remove containers4. CI/CD with GitHub Actions
A well-structured pipeline has three jobs: test → build → deploy.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm run build # type-check + build
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build & push Docker image
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker build -t ghcr.io/${{ github.repository }}:latest .
docker push ghcr.io/${{ github.repository }}:latest
- name: Deploy to VPS via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
docker pull ghcr.io/${{ github.repository }}:latest
docker stop app || true
docker rm app || true
docker run -d --name app -p 3000:3000 \
--env-file /etc/myapp/.env \
ghcr.io/${{ github.repository }}:latestAdd secrets in GitHub → Settings → Secrets and variables → Actions.
5. Zero-Downtime Deploys with Nginx
On your VPS, use Nginx as a reverse proxy so users never see downtime:
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name sabirsoft.com;
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;
proxy_cache_bypass $http_upgrade;
}
}Enable HTTPS with Certbot (Let's Encrypt):
sudo certbot --nginx -d sabirsoft.comFor true zero-downtime, use Docker's --stop-timeout and health checks:
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 16. Environment Variables & Secrets
Never commit .env to git. Use:
- Vercel → Project Settings → Environment Variables
- VPS →
/etc/myapp/.env(only root-readable:chmod 600) - GitHub Actions → Repository Secrets
In Next.js, prefix public vars with NEXT_PUBLIC_:
# Server-only (safe)
DATABASE_URL=mongodb+srv://...
RESEND_API_KEY=re_...
# Exposed to browser (public)
NEXT_PUBLIC_SITE_URL=https://sabirsoft.comChecklist Before Going to Production
- Multi-stage Dockerfile with
standaloneoutput -
.dockerignoreexcludesnode_modules,.next,.git - CI runs lint + build before deploy
- Nginx with HTTPS (Certbot)
- Health check endpoint at
/api/health - Secrets managed via environment files, not hardcoded
This stack — Docker + GitHub Actions + Nginx — is battle-tested and scales from a $5 VPS to a Kubernetes cluster without changing your application code.