Skip to main content

28 — Cheat-sheets

Read this first: four one-page references, one per track. These are for the commands you will use weekly and forget monthly. Keep this page open while you work; there is no prize for recalling flags from memory.

Bash​

set -euo pipefail # errexit, nounset, pipefail — the first line of every script
NeedWrite
Arguments$1 $2, all as separate words "$@", count $#, script name $0
Default if unset${VAR:-default}
Required, or die${VAR:?message}
Allow unset under -u${VAR:-}
Only if set${VAR:+text}
Length${#VAR}
Substring${VAR:0:12}
Strip from left${VAR#pat} shortest · ${VAR##pat} longest
Strip from right${VAR%pat} shortest · ${VAR%%pat} longest
Basename / dirname${path##*/} / ${path%/*}
[ -z "$x" ] # empty [ -n "$x" ] # non-empty
[ -f "$p" ] # file exists [ -d "$p" ] # directory exists
[[ "$x" =~ ^re ]] # regex — the only reason to use [[ ]]
cmd || true # tolerate an expected failure
{ cmd || true; } | other # ...scoped to cmd, not the pipeline
a && b && c # chain: any failure stops the rest
cmd1 || { echo "msg" >&2; exit 1; }

arr=(-f a.yml -f b.yml) # array
cmd "${arr[@]}" # expands to 0+ correctly-separated words
mapfile -t arr < <(gen) # read lines into an array

while IFS= read -r line; do ...; done < <(cmd) # NOT `cmd | while` — subshell!

case "$v" in
a|b) ... ;;
*.sh) ... ;; # globs, not regex
*) ... ;;
esac

exec 200>/path/lock # open FD 200
flock -w 900 200 || exit 1 # exclusive lock, released when the script exits

trap 'rm -rf "$TMP"' EXIT # cleanup on any exit
VAR=value cmd # set for this one command only

Traps to remember: quote every expansion · set -e is suspended inside if/&&/||/! · a pipe into while loses your variables · [ ] word-splits, [[ ]] does not.

Linux triage​

docker ps # 1. what is running / healthy
docker logs --tail 100 -f <name> # 2. what did it say
sudo ss -tlnp # 3. what holds the port
df -h / && docker system df # 4. is the disk full
NeedCommand
Health status onlydocker inspect -f '{{.State.Health.Status}}' <name>
Memory hogsps aux --sort=-%mem | head
Container resourcesdocker stats --no-stream
Free memoryfree -h
Who am I, what groupsid
What owns a big directorydu -sh /var/lib/docker/*
Reclaim spacedocker image prune → docker system prune (never --volumes on a data host)
chmod 600 file # rw------- private key, env file
chmod 700 dir # rwx------ ~/.ssh
umask 077 # new files 600, new dirs 700 — set BEFORE writing
stat -c %a file # numeric mode
ssh -i key -p 2222 user@host # log in
ssh -i key -p 2222 user@host 'cmd' # run one command (what CI does)
scp -i key -P 2222 file user@host:/dst # note capital -P
ssh -L 3000:localhost:3000 user@host # tunnel to a loopback-bound service
ssh -vvv ... # why is the key rejected

Permission denied (publickey) tells you nothing — read the server's log.

sudo iptables -S DOCKER-USER # what is actually there
EXT_IF=$(ip route show default | awk '{print $5; exit}')
sudo iptables -I DOCKER-USER 1 ! -i "$EXT_IF" -j RETURN # MUST be first
sudo netfilter-persistent save # or it dies at reboot

UFW does not protect a published container port. DOCKER-USER sees both directions.

Docker and Compose​

docker build -t name:tag --build-arg APP_VERSION=v1 .
docker build --target build -t x . # build one stage only
docker run -d --name n -p 8080:80 -e K=v -v vol:/path image
docker exec -it n sh # shell in a running container
docker rm -f n # force-remove
FROM base AS build # stage 1
COPY pom.xml . # manifest FIRST
RUN mvn dependency:go-offline # so this layer survives a source edit
COPY src ./src
RUN mvn package -DskipTests

FROM slim-base AS runtime # stage 2 — only this ships
COPY --from=build /build/target/app.jar ./
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
USER app
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
docker compose up -d --build
docker compose ps
docker compose logs -f <service>
docker compose exec <service> sh
docker compose config # THE fully-resolved file — use before guessing
docker compose down # stop
docker compose down -v # stop AND DELETE VOLUMES
services:
api:
image: ghcr.io/you/api:${IMAGE_TAG:?set by deploy.sh}
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:8080/health | grep -q UP || exit 1']
interval: 10s
start_period: 60s # grace window; failures here are free
depends_on:
db:
condition: service_healthy # NOT plain `depends_on: [db]`

Traps: service names are hostnames, localhost is the container · no ports: means unreachable · single-quote env values containing $ (double quotes do NOT help) · shell env beats --env-file · probe 127.0.0.1, not localhost · down -v deletes data.

Reverse proxies​

Caddy — configured. Named upstreams in a file; edit and reload to change.

app.example.com {
@api path /api/* /ws /ws/*
handle @api {
reverse_proxy backend:8080 {
header_up X-Forwarded-For {header.CF-Connecting-IP}
}
}
handle {
reverse_proxy frontend:80
}
}
caddy validate --config Caddyfile --adapter caddyfile # check BEFORE reloading
docker cp Caddyfile edge:/tmp/Caddyfile # copy in, don't reload the mount
docker exec edge caddy reload --config /tmp/Caddyfile --adapter caddyfile

Traefik — discovered. Routes live on the application container as labels.

labels:
traefik.enable: 'true' # opt-in
traefik.http.routers.app.rule: 'Host(`app.example.com`)'
traefik.http.routers.app.entrypoints: 'websecure'
traefik.http.routers.app.tls.certresolver: 'le'
traefik.http.services.app.loadbalancer.server.port: '8080'
command: # on the Traefik container
- --providers.docker=true
- --providers.docker.exposedbydefault=false # ALWAYS set this
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
curl -s localhost:8080/api/http/routers # what did it actually discover
curl -s localhost:8080/api/http/services # ...and which backends
Traefik termMeans
EntrypointA port it listens on
RouterA rule matching requests
ServiceThe backend(s) they go to
MiddlewareApplied in between — headers, auth, rate limit
ProviderWhere config comes from (Docker socket, file, Consul…)

Traps: without exposedbydefault=false every container is published · two containers claiming one router name silently round-robin, no error · Traefik needs the Docker socket, which is root-equivalent · a discovery failure shows up as a 404, not as a discovery error.

Across machines​

# the first error after a split is name resolution, not networking
java.net.UnknownHostException: motorph_payroll_db # bridge DNS is per-daemon

psql -h 10.9.0.30 -U greeter -d greeter \
-tAc "select ssl from pg_stat_ssl where pid=pg_backend_pid()" # is the wire encrypted?
command: [postgres, -c, listen_addresses=*] # now pg_hba + firewall + TLS are YOUR problem
sudo ufw allow from 198.51.100.20 to any port 5432 proto tcp # app server only
sudo ufw deny 5432/tcp

Checklist when moving a service to its own host: names → addresses · listen address · TLS with sslmode=verify-full · pg_hba.conf · firewall (DOCKER-USER if containerised) · backups that no longer docker exec · monitoring agents are per-host.

GitHub Actions​

name: Deploy
on:
push:
branches: [main]
paths: ['src/**', '!docs/archive/**']
workflow_dispatch:
inputs:
tag: { description: 'Image tag', required: false, default: '' }

concurrency:
group: deploy
cancel-in-progress: false # true for rollback — it must preempt

permissions:
contents: read # declaring this ZEROES every unlisted scope

jobs:
build:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4 # NOT automatic
- id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"

deploy:
needs: build
if: github.ref == 'refs/heads/main'
environment: Production # ALSO a secrets scope
steps:
- run: echo "${{ needs.build.outputs.tag }}"
NeedWrite
Value to a later stepecho "k=v" >> "$GITHUB_OUTPUT"
Value to a later joboutputs: on the job, then needs.<job>.outputs.<k>
Files between jobsactions/upload-artifact / download-artifact
Run despite failureif: always() or if: ${{ !cancelled() }}
Ternary`${{ cond && 'yes'
Surface a messageecho "::error::msg" / ::warning::
Default for a secret${{ secrets.PORT || 22 }}
- uses: appleboy/ssh-[email protected]
env:
PROD_ENV: ${{ secrets.PROD_ENV_FILE }}
with:
host: ${{ secrets.VPS_HOST }} # an IP, not the domain
key: ${{ secrets.VPS_SSH_KEY }}
envs: PROD_ENV # NEVER ${{ }} the content into script:
script: |
set -euo pipefail
umask 077
[ -n "$PROD_ENV" ] || { echo "empty secret" >&2; exit 1; }
printf '%s\n' "$PROD_ENV" > .env

Traps: a missing secret is an empty string, not an error · environment: scopes secrets · concurrency keeps one running + one pending, replacing the pending · forgetting push: true produces a green build that shipped nothing · YAML: quote version numbers, no is false, 3.10 is 3.1.

The five sentences worth memorising​

  1. Zero is success; anything else is failure.
  2. A container is a process, not a machine — when it exits, the container stops.
  3. Copy the dependency manifest before the source, or every build is a cold build.
  4. set -e is suspended inside if — &&-chain anything that must fail as a unit.
  5. Green means the new version is live, not that the site is up.