DevOps snippets: Docker, Nginx, Git, GitHub Actions
Sixteen operational snippets: multi-stage Docker builds and non-root containers, Nginx reverse proxy with SSL, load balancing and WebSocket proxying, Git rebase, stash and bisect workflows, and GitHub Actions for CI, deploys and cron.
Everything here runs between "the code is written" and "the code is serving traffic". Four tools, one job: getting a build out and keeping it reachable.
Docker: the image size problem is a build-stage problem
A single-stage Node image ships the compiler, the dev dependencies and the source alongside the thing you actually run. Multi-stage builds solve this by copying only the build output into a clean runtime image, and the difference is usually not marginal — it is most of the image. Smaller images pull faster on every deploy and present a smaller surface to scan.
Running as a non-root user is the other half. Containers do not isolate as strongly as a VM, and a process running as root inside the container is running as a user that maps to root on the host in the default configuration. Adding a user and dropping to it costs two lines. Pairing it with a read-only root filesystem costs one more and removes an entire category of persistence.
Nginx: proxying is easy, WebSockets are the exception
A reverse proxy for HTTP is four directives. WebSockets need two more — Upgrade and Connection — because the protocol switch will not happen without them, and the failure mode is confusing: the handshake returns 200 instead of 101 and the client retries forever. It is the single most common Nginx question for anyone adding real-time features to a working site.
The SSL configuration is where defaults matter most: the Mozilla SSL configuration generator stays current with what is considered safe, and copying a five-year-old cipher list from a blog post is how sites end up failing an audit.
Git: three commands for three different situations
Stash is for interrupted work you will return to in minutes. Interactive rebase is for tidying a branch before review — squashing the "fix typo" commits so the history describes intent rather than the order things occurred to you. Bisect is for the question "when did this break", and it is dramatically faster than reading diffs: it finds the offending commit in a handful of steps regardless of how many commits are in range.
GitHub Actions: the deploy is the part with secrets in it
The CI workflow is mostly plumbing. The deploy workflow is where care is required — SSH keys belong in encrypted secrets, never in the repository, and a scheduled workflow needs to be idempotent because GitHub does not guarantee exact timing and will occasionally skip or delay a run.
Docker Compose for Node.js + PostgreSQL + Redis
A production-style docker-compose.yml for a Node.js app with PostgreSQL and Redis — the standard SaaS backend stack.
How it works: three services — app (Node.js), db (PostgreSQL 16), and cache (Redis 7) — on a shared network. The app waits for both to be healthy before starting. Volumes keep data across restarts, and .env is loaded automatically.
services:
app:
build: .
ports:
- "3000:3000"
env_file: .env
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
volumes:
- .:/app
- /app/node_modules
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:
# Start all services
docker compose up -d
# Follow logs
docker compose logs -f app
# Tear down (keeps volumes)
docker compose down
# Tear down completely (removes data)
docker compose down -v
Docker multi-stage builds for Node.js
A production Dockerfile with multi-stage builds — separate deps, build, and runtime stages for a minimal final image.
How it works: three stages. deps installs dependencies, builder compiles the app, and runner copies only what's needed into a slim image. No dev dependencies, no source code — just the compiled output and production node_modules.
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable pnpm && pnpm build
# Stage 3: Production
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 appuser
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:nodejs /app/package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
docker build -t myapp:latest .
docker images --filter reference=myapp
# Final image is ~150MB instead of ~1.2GB
Docker security — non-root user and read-only filesystem
Harden your containers by running as a non-root user with a read-only root filesystem and dropped kernel capabilities.
How it works: create an unprivileged user inside the image, switch to it with USER, then pass security options at runtime — --read-only prevents writes to the container filesystem, --cap-drop=ALL removes all kernel capabilities, and --security-opt=no-new-privileges prevents privilege escalation.
FROM node:20-alpine
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
# Run with security hardening
docker run --rm \
--read-only \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
--tmpfs /tmp \
-p 3000:3000 \
myapp:latest
# docker-compose equivalent
# security_opt:
# - no-new-privileges:true
# cap_drop:
# - ALL
# read_only: true
# tmpfs:
# - /tmp
Nginx load balancing between Node.js instances
Distribute traffic across multiple Node.js backend instances with round-robin, health checks, and passive failover — no application changes needed.
How it works: the upstream block defines a group of backend servers. Nginx distributes requests using round-robin by default. If a server fails to respond, nginx marks it as unavailable and routes traffic to the remaining healthy servers.
upstream node_backend {
# Round-robin load balancing (default)
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
# Optional: least-connected distribution
# least_conn;
# Optional: IP-based sticky sessions
# ip_hash;
# Mark server as down for maintenance
# server 127.0.0.1:3004 down;
# Passive health check
# After 3 failures, mark unavailable for 30s
server 127.0.0.1:3005 max_fails=3 fail_timeout=30s;
}
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://node_backend;
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_set_header X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
proxy_send_timeout 10s;
}
}
# Scale Node.js instances with PM2
pm2 start server.js -i 3
Nginx reverse proxy with SSL termination
A hardened nginx configuration for proxying Node.js apps — SSL termination, rate limiting, security headers, and gzip.
How it works: nginx terminates TLS at the edge, forwards requests to a local Node.js backend, and adds security headers, rate limiting, and gzip compression — all before a single line of app code runs.
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Rate limiting
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript;
location / {
proxy_pass http://127.0.0.1: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_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
# Test config before reload
sudo nginx -t
# Reload gracefully
sudo nginx -s reload
Nginx static file server with caching
Serve static assets (CSS, JS, images) with far-future cache headers, gzip compression, and directory index — no app server needed.
How it works: nginx serves files from /var/www/html directly — no proxy. Cache headers tell the browser to store assets for a year; files with content hashes in their name are safe to cache forever. Gzip reduces transfer size, and try_files enables SPA routing.
server {
listen 80;
server_name example.com;
root /var/www/html;
index index.html;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
gzip_min_length 256;
# Far-future cache for versioned assets
location ~* \.(?:css|js|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache images for a week
location ~* \.(?:jpg|jpeg|png|webp|avif)$ {
expires 7d;
add_header Cache-Control "public";
}
# No cache for HTML
location ~* \.html?$ {
expires -1;
add_header Cache-Control "no-cache";
}
# SPA fallback — serve index.html for non-file routes
location / {
try_files $uri $uri/ /index.html;
}
# Deny access to hidden files
location ~ /\. {
deny all;
}
}
Nginx WebSocket proxy for real-time apps
Proxy WebSocket connections through nginx — required for Socket.io, live chat, and collaborative editing behind a reverse proxy.
How it works: WebSocket connections start as HTTP upgrades. The Upgrade and Connection headers must be forwarded explicitly. Without this configuration, nginx closes the connection after 60s. The proxy_read_timeout of 1h keeps long-lived WebSocket connections alive.
server {
listen 80;
server_name app.example.com;
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# WebSocket upgrade headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Standard proxy headers
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_set_header X-Forwarded-Proto $scheme;
# Long timeout for persistent connections
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Regular HTTP traffic
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
# Test WebSocket connection
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: $(openssl rand -base64 16)" http://app.example.com/ws
Git cherry-pick and bisect — target specific commits
Cherry-pick applies a specific commit from any branch to your current branch. Bisect performs a binary search through history to find the commit that introduced a bug.
How it works: cherry-pick takes the diff from a single commit and applies it to HEAD — no merge, no rebase. bisect marks commits as good or bad, and Git narrows down the exact commit where things broke, usually in under 10 steps even for thousands of commits.
# Cherry-pick a single commit
git cherry-pick a1b2c3d
# Cherry-pick with auto-commit message
git cherry-pick --no-commit a1b2c3d
# Cherry-pick a range of commits
git cherry-pick a1b2c3d..e5f6g7h
# Cherry-pick from another branch
git checkout main
git cherry-pick feature-branch~3..feature-branch
# Start bisect session
git bisect start
git bisect bad # current commit is broken
git bisect good v2.0.0 # last known good tag
# Git checks out the midpoint — test it, then:
git bisect good # or git bisect bad
# Repeat until Git identifies the first bad commit
# End bisect session
git bisect reset
# See what changed in a specific commit
git show a1b2c3d
Git log — filtering and formatting commit history
Search, filter, and format your Git log — find commits by author, date, file, or content with a readable one-line output.
How it works: git log accepts dozens of filtering flags. Combine them to find exactly the commits you need — who changed a file, when, and why — without scrolling through irrelevant history.
# One line per commit
git log --oneline
# Graph view with branches
git log --oneline --graph --all
# Commits by author
git log --author="Alex"
# Commits since a date
git log --since="2026-06-01"
git log --after="2 weeks ago"
# Commits that modified a specific file
git log --oneline -- src/server.ts
# Search commits by message
git log --grep="fix:" --oneline
# Search commits that added/removed a function
git log -S "calculateTotal" --oneline
# Commits in a date range
git log --since="2026-06-01" --until="2026-07-01"
# Custom format
git log --pretty=format:"%h %an %ar %s"
# Show diff stats
git log --stat --oneline
# Last 5 commits
git log -5 --oneline
# Commits in branch not in main
git log main..feature --oneline
Git squash commits and interactive rebase
Clean up your Git history with squash, fixup, and interactive rebase — keep a readable commit log before merging.
How it works: interactive rebase (git rebase -i) opens an editor where you can squash, fixup, reword, or reorder commits. Squash combines a commit into the previous one and lets you edit the message; fixup does the same but discards the message — perfect for "fix typo" and "wip" commits.
# Squash the last 3 commits into one
git rebase -i HEAD~3
# Editor opens with:
# pick a1b2c3d Add login page
# pick e4f5g6h Fix typo on login page
# pick i7j8k9l Add tests for login
# Change to:
# pick a1b2c3d Add login page
# squash e4f5g6h Fix typo on login page
# fixup i7j8k9l Add tests for login
# Save and close → one clean commit
# Squash all commits on a feature branch into one
git checkout feature/my-feature
git rebase -i main
# Mark all commits except the first as "squash"
# or use the one-liner:
git reset --soft main
git commit -m "feat: add my feature"
# Amend the last commit (if not pushed yet)
git add . && git commit --amend --no-edit
# Force push after rebase (only on feature branches!)
git push --force-with-lease
Git stash — save and restore work in progress
Temporarily save uncommitted changes with git stash — switch branches, pull updates, and restore your work later without losing anything.
How it works: git stash takes all tracked-but-uncommitted changes and pushes them onto a stack. Your working tree becomes clean so you can switch branches. git stash pop restores the latest stash and removes it from the stack.
# Save current work (tracked files only)
git stash
# Save including untracked files
git stash -u
# Save with a descriptive message
git stash push -m "WIP: refactoring auth middleware"
# List all stashes
git stash list
# stash@{0}: On feature/login: WIP: refactoring auth middleware
# stash@{1}: WIP: fixing header styles
# Restore the latest stash
git stash pop
# Restore a specific stash
git stash pop stash@{1}
# Apply stash without removing it from the stack
git stash apply stash@{0}
# Create a branch from a stash (handles conflicts)
git stash branch feature/new-branch stash@{0}
# Drop a specific stash
git stash drop stash@{1}
# Clear all stashes
git stash clear
GitHub Actions — CI pipeline for PRs
Run linting, type-checking, and tests on every pull request before merge — fail fast with clear annotations on the PR.
How it works: triggered on pull_request to main, the workflow sets up Node.js, installs deps with caching, runs lint, type-check, and tests in parallel, then uploads test results as annotations on the PR.
name: CI
on:
pull_request:
branches: [main]
jobs:
quality:
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 typecheck
- run: npm test -- --coverage
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
src:
- 'src/**'
- name: Upload coverage
if: steps.changes.outputs.src == 'true'
uses: codecov/codecov-action@v4
# Required branch protection rules:
# Require status check "quality" before merge
GitHub Actions — deploy Node.js app to VPS via SSH
A CI/CD pipeline that lints, tests, builds, and deploys a Node.js app to a VPS over SSH with zero-downtime.
How it works: on every push to main, the workflow checks out the code, installs dependencies, runs linting and tests, builds the project, syncs the build to the VPS via rsync over SSH, and performs a zero-downtime reload with PM2.
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
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 test
- run: npm run build
- name: Deploy via rsync
uses: easingthemes/ssh-deploy@v4
with:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
REMOTE_HOST: ${{ secrets.REMOTE_HOST }}
REMOTE_USER: ${{ secrets.REMOTE_USER }}
SOURCE: .output/
TARGET: /var/www/app/
ARGS: -rlpDzvc --delete
- name: Reload app
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.REMOTE_HOST }}
username: ${{ secrets.REMOTE_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/app
pm2 reload ecosystem.config.js
# Required secrets for the workflow:
# SSH_PRIVATE_KEY — your deploy key
# REMOTE_HOST — VPS IP or domain
# REMOTE_USER — SSH user
GitHub Actions — build and push Docker image to registry
Automatically build and push a Docker image to Docker Hub or GitHub Container Registry on every push to main — with layer caching.
How it works: on push to main, the workflow logs into the container registry, sets up Docker Buildx for multi-platform builds, uses GitHub Actions cache for layers, builds the image with version tags, and pushes it. Pull requests get an image built but not pushed.
name: Docker Build & Push
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=ref,event=branch
type=sha,format=short
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
GitHub Actions — scheduled tasks with cron
Run workflows on a schedule — daily backups, weekly dependency updates, or hourly health checks — using cron syntax.
How it works: the schedule event uses POSIX cron syntax. The workflow runs at the specified intervals regardless of pushes. Use workflow_dispatch to also trigger it manually for testing.
name: Daily Backup
on:
schedule:
# Every day at 2:00 AM UTC
- cron: '0 2 * * *'
workflow_dispatch:
jobs:
backup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create database dump
run: |
pg_dump $DATABASE_URL > backup_$(date +%Y-%m-%d).sql
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Upload to S3-compatible storage
uses: jakejarvis/s3-sync-action@v1
with:
args: --acl private
env:
AWS_S3_BUCKET: ${{ secrets.AWS_BACKUP_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
SOURCE_DIR: .
# Common cron schedules:
# Every hour: 0 * * * *
# Daily at midnight: 0 0 * * *
# Weekly on Monday: 0 0 * * 1
# Monthly on 1st: 0 0 1 * *
# Every 15 minutes: */15 * * * *
yt-dlp — Handy Bash Functions for Video, Audio & Subtitles
A set of functions for .bashrc / .zshrc to quickly download videos (up to 1080p), export MP3 audio, extract clean subtitles without timestamps, and save thumbnails.
A ready-to-use set of shell functions to append to your ~/.bashrc or ~/.zshrc. Utilizes Firefox cookies, Node.js runtime, and bypasses common download throttling with yt-dlp.
Function Reference & Mnemonic Trick:
The dlp prefix can be remembered as short for DownLoad Please (or yt-dlp), while the suffix specifies the output format:
dlpv URL— DownLoad Please Video — download video capped at 1080p (MP4 + M4A).dlpa URL— DownLoad Please Audio — extract and convert audio stream to MP3.dlps URL— DownLoad Please Subtitles — download clean plain-text subtitles without the video.dlpvs URL— DownLoad Please Video + Subtitles — download video with cleaned English subtitles.dlpvt URL— DownLoad Please Video + Thumbnail — download video along with its original thumbnail.
# video
dlpv() {
yt-dlp --cookies-from-browser Firefox --js-runtime node --remote-components ejs:github -f "bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" "$1"
}
# audio
dlpa() {
yt-dlp --cookies-from-browser Firefox --js-runtime node --remote-components ejs:github -x --audio-format mp3 -o "%(title)s [%(id)s].%(ext)s" "$1"
}
# video & subtitles
dlpvs() {
yt-dlp --cookies-from-browser Firefox --js-runtime node --remote-components ejs:github --write-subs --write-auto-subs --sub-lang en --sub-format ttml --convert-subs txt --exec before_dl:"sed -e '/^[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9] --> [0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9]$/d' -e '/^[[:digit:]]\{1,3\}$/d' -e 's/<[^>]*>//g' -e '/^[[:space:]]*$/d' -i '' %(requested_subtitles.:.filepath)#q" -f "bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" "$1"
}
# subtitles
dlps() {
yt-dlp --cookies-from-browser Firefox --js-runtime node --remote-components ejs:github --skip-download --write-subs --write-auto-subs --sub-lang en --sub-format ttml --convert-subs txt --exec before_dl:"sed -e '/^[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9] --> [0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9]$/d' -e '/^[[:digit:]]\{1,3\}$/d' -e 's/<[^>]*>//g' -e '/^[[:space:]]*$/d' -i '' %(requested_subtitles.:.filepath)#q" "$1"
}
# video & thumbnail
dlpvt() {
yt-dlp --cookies-from-browser Firefox --js-runtime node --remote-components ejs:github --write-thumbnail -f "bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" "$1"
}
More snippet collections
Contact Information
Submit a request or write to me directly on Telegram. I'll reply within 24 hours.