DevOps in 15 Minutes: A Crash Course for Engineers Who Build Things
You already know code. Let me map infrastructure onto what you know.
I’m a self-taught senior staff engineer. I’ve built mobile apps, web platforms, recording studios, and a home lab running a dozen services on self-hosted hardware. I’ve never had a systems administration class. And I just migrated my entire infrastructure — Git hosting, CI/CD, databases, monitoring, and a reverse proxy — from a tangle of manual SSH sessions into a reproducible, version-controlled stack that I can blow away and rebuild in under an hour.
This is the crash course I wish someone had given me. No AWS certification prerequisites. No “cloud architect” gatekeeping. Just the concepts, mapped onto things you already understand.
If you’ve ever written a program that runs on your machine, you already have the mental models for all of this.
A Container Is a Process With Its Own Filesystem
You know how a process runs in its own memory space? A container is the same idea extended to the filesystem, the network, and the process table.
When you run docker run nginx, you’re not starting a virtual machine. You’re starting a Linux process that thinks it has its own root filesystem, its own network interface, and its own PID 1. Under the hood, it’s sharing the host kernel. It’s just isolated — namespaced, in Linux terms.
Think of it as chroot on steroids. The process can’t see files outside its mount, can’t see other processes outside its namespace, can’t bind to ports on the host unless you map them.
That’s it. A container is a process with better boundaries. Everything else in Docker is details about which boundaries, how to build them, and how to connect them.
A Dockerfile Is a Makefile
A Makefile describes how to build your binary from source. A Dockerfile describes how to build your container image from a base image and a set of instructions.
FROM node:20-alpine # Start with this base image
WORKDIR /app # cd /app
COPY package*.json ./ # Copy dependency manifests
RUN npm ci # Install dependencies
COPY . . # Copy source code
RUN npm run build # Build the app
CMD ["node", "dist/index.js"] # Default entry point
Each instruction creates a layer. Layers are cached. If package.json didn’t change, Docker skips the npm ci step. It’s the same optimization as Make skipping targets whose dependencies haven’t changed.
The output is an image — your compiled artifact. You push it to a registry (like pushing a binary to an artifact store), and any machine that can pull it can run it. Build once, run anywhere. That promise finally works.
Docker Compose Is package.json for Services
Your package.json declares your app’s dependencies and how to run it. docker-compose.yml declares your app’s service dependencies and how to run all of them together.
services:
app:
build: .
ports:
- "127.0.0.1:3000:3000"
depends_on:
- db
environment:
DATABASE_URL: postgres://user:pass@db:5432/myapp
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
docker compose up is npm install && npm start for your entire stack. One command, everything runs. The app can reach the database by hostname db because Compose creates a virtual network where service names resolve to container IPs.
docker compose down tears it all down. docker compose up -d runs it in the background. Your entire development environment is a YAML file in your repo.
Volumes Are State That Survives Restarts
Containers are ephemeral. When a container dies, its filesystem dies with it. Volumes are the exception — they’re directories on the host that get mounted into the container.
Think of them as symlinks that survive container restarts. Your database stores files in /var/lib/postgresql/data inside the container, but that path is actually backed by a directory on the host. Kill the container, start a new one, mount the same volume — your data is still there.
Named volumes (pgdata:) are managed by Docker. Bind mounts (./data:/app/data) point to a specific host directory. Named volumes for databases, bind mounts for config files and development code.
The rule: anything you can’t afford to lose goes in a volume. Everything else is disposable.
Networking Is Just Port Mapping and DNS
Your app listens on port 3000. The outside world needs to reach it. The mapping is:
host_ip:host_port:container_port
127.0.0.1:3000:3000 means only localhost can reach it. 0.0.0.0:3000:3000 (or just 3000:3000) means anyone who can reach the host can reach your app. The first form is what you want behind a reverse proxy. The second form is what gets you in the security article.
Inside a Compose network, containers resolve each other by service name. Your app connects to db:5432 and Docker’s internal DNS resolves db to the database container’s IP. No hardcoded IPs. No host networking hacks. Service discovery by hostname, same as how your browser resolves google.com.
A Reverse Proxy Is a Router
You know how a frontend router maps URL paths to components? A reverse proxy maps URL paths (and hostnames) to backend services.
gitea.yourdomain.com → 127.0.0.1:3000 (Gitea)
ci.yourdomain.com → 127.0.0.1:8000 (Woodpecker)
app.yourdomain.com → 127.0.0.1:4000 (Your app)
One IP address, one port 443, multiple services. The reverse proxy reads the Host header on the incoming request, looks up which backend handles it, forwards the request, and returns the response. It’s express.Router() for your entire server.
Caddy does this and handles TLS certificates automatically. Your Caddyfile is a few lines:
gitea.yourdomain.com {
reverse_proxy 127.0.0.1:3000
}
Caddy provisions a Let’s Encrypt certificate, renews it before expiry, terminates TLS, and proxies to your service. No certbot cron jobs. No nginx config. Three lines.
DNS Is a Global Key-Value Store
When someone types yourdomain.com, their browser asks a DNS resolver: “What IP address does this name map to?” DNS returns the answer. That’s the whole thing. A global, distributed, eventually-consistent key-value store where the keys are domain names and the values are IP addresses (and a few other record types).
The records you care about:
- A record:
yourdomain.com → 203.0.113.10— name to IPv4 address - CNAME record:
www.yourdomain.com → yourdomain.com— alias to another name - MX record:
yourdomain.com → mail.yourdomain.com— where email goes - TXT record: arbitrary string, used for verification and SPF/DKIM
TTL (Time To Live) is the cache expiration. A TTL of 3600 means resolvers cache the answer for one hour. Lower TTL means faster propagation when you change IPs. Higher TTL means fewer lookups but slower updates.
When you point a domain at your server, you’re writing a key-value pair: “this name resolves to this IP.” The rest is caching, propagation, and the registrar’s UI.
SSH Is a Remote REPL
You use a REPL to interact with a running interpreter. ssh user@server gives you a REPL for a remote machine. You type commands, the remote shell executes them, the output comes back over an encrypted tunnel.
Key-based auth works like API keys. Your private key (~/.ssh/id_ed25519) is your secret. Your public key (~/.ssh/id_ed25519.pub) goes on the server’s ~/.ssh/authorized_keys. When you connect, the server challenges you to prove you have the private key without revealing it. It’s asymmetric cryptography as authentication — the same concept as JWT signing.
SSH agent forwarding is like passing your credentials down a call chain. You SSH into server A, and from there SSH into server B, using the keys from your local machine without copying them to server A. Your key never leaves your laptop. The agent on your machine handles the signing remotely.
scp and rsync are file copy over SSH. ssh-copy-id is the setup command. ssh -L 8080:localhost:3000 server is port forwarding — it tunnels the server’s port 3000 to your local port 8080, like a VPN for one port.
TLS Is a Handshake Before the Conversation
When your browser connects to an HTTPS site, the first thing that happens is a TLS handshake. The server presents its certificate. The browser verifies the certificate was signed by a trusted CA (Certificate Authority). They negotiate an encryption key. Then the actual HTTP conversation happens, encrypted.
It’s the same pattern as OAuth. Before you can call the API, you exchange credentials and establish a session. TLS is the transport-layer version of that. The certificate is the server’s proof of identity. The CA is the identity provider. The negotiated key is the session token.
Let’s Encrypt made certificates free and automated. Caddy makes them invisible. If you’re manually managing certificates in 2026, you’re solving a problem that’s been solved.
The mental model: TLS is the authentication middleware that runs before any of your route handlers see the request.
CI/CD Is a Git Hook That Builds and Ships
You know pre-commit hooks — scripts that run before a commit is finalized. CI/CD is a post-push hook that runs on a server.
You push code. The CI server detects the push (via webhook or polling). It clones your repo, runs your pipeline — lint, test, build, deploy — and reports the result. Green means it worked. Red means it didn’t.
The pipeline is code. In Woodpecker CI it’s .woodpecker.yml. In GitHub Actions it’s .github/workflows/*.yml. In both cases, it’s a declarative description of what to run and in what order, version-controlled alongside your source.
Continuous Integration means every push triggers the pipeline. No manual builds. No “it works on my machine.” The CI server is the single source of truth for whether the code builds and the tests pass.
Continuous Deployment means a green pipeline automatically deploys. No deploy meetings. No release manager clicking a button. Push, test, ship. The pipeline is the deploy process.
Self-hosted CI (Woodpecker on your own hardware) means your builds run on your machine, your secrets stay on your machine, and you don’t pay per-minute for compute. The pipeline for a small project is 15 lines of YAML.
Infrastructure as Code Is git diff for Servers
You don’t configure your app by clicking through a GUI and hoping you remember what you changed. You write config files, commit them, diff them, review them.
Infrastructure as Code is the same principle applied to servers. Instead of SSHing in and running apt install nginx by hand, you write a playbook, a manifest, or a Compose file that describes the desired state. Then you run the tool and it makes reality match the description.
Docker Compose is IaC for local services. Ansible is IaC for remote servers — you describe tasks (install this package, copy this file, restart this service) and Ansible executes them over SSH. Terraform is IaC for cloud resources — VMs, DNS records, load balancers.
The value isn’t the tool. It’s the git diff. When something breaks, you can look at what changed. When you need to rebuild, you run the same file. When a new team member asks “how is this set up,” the answer is in the repo, not in someone’s head.
A server configured by hand is an undocumented API. A server configured by code is a versioned spec.
Cron Is setInterval for Your Server
setInterval(() => backup(), 86400000) runs a backup every 24 hours in Node. Cron does the same thing at the OS level.
0 3 * * * /usr/local/bin/backup.sh
This runs backup.sh at 3:00 AM every day. The five fields are: minute, hour, day-of-month, month, day-of-week.
Cron jobs are the scheduled tasks of server operations — backups, log rotation, certificate renewal (before Caddy), health checks, cache cleanup. They’re invisible, they run in the background, and they fail silently unless you redirect output to a log.
The gotcha: cron runs in a minimal environment. Your PATH, your environment variables, your shell aliases — none of them exist in cron’s context. Use absolute paths for everything. Redirect stderr to a log file. Test the command by hand before scheduling it.
systemd timers are the modern alternative with better logging and dependency management, but cron is universal and understood. Same concept, different scheduler.
Systemd Is Your Process Manager
Your app crashes. Who restarts it? In development, you do. In production, systemd does.
Systemd is the init system on most Linux distributions. It starts services on boot, restarts them on failure, manages dependencies between services, and captures their logs.
A systemd service file is a process declaration:
[Unit]
Description=My Application
After=network.target
[Service]
ExecStart=/usr/local/bin/myapp
Restart=on-failure
User=appuser
[Install]
WantedBy=multi-user.target
This says: start myapp after the network is up, restart it if it crashes, run it as appuser, and include it in the normal boot sequence. It’s pm2 or supervisor but built into the OS.
systemctl start myapp starts it. systemctl enable myapp makes it start on boot. journalctl -u myapp -f tails the logs. Docker handles this for containers (with restart: unless-stopped in Compose), but for anything running directly on the host, systemd is the process manager.
Monitoring Is console.log for Production
In development, you add console.log and check the terminal. In production, you add structured logs and check a log aggregator.
The stack: your app writes JSON logs to stdout. A collector (Promtail, Filebeat, Alloy) ships them to a store (Loki, Elasticsearch). A dashboard (Grafana) lets you query them.
Logs answer: what happened? They’re your event stream. Structured JSON logs with consistent fields (timestamp, level, service, message) are searchable. Unstructured console.log("here") messages are not.
Metrics answer: how much? Request count, response time, error rate, CPU usage, memory usage. Prometheus scrapes a /metrics endpoint on your services every 15 seconds and stores the time series. Grafana graphs it.
Alerts answer: is something wrong? A Prometheus rule that fires when error rate exceeds 5% for 5 minutes, routed to your phone via Alertmanager. The three things that deserve alerts: the app is down, the app is slow, the disk is full. Everything else is a dashboard you check when you’re curious.
The production debugging workflow: alert fires → check dashboard for which metric is off → query logs for the time window → find the error → fix it. Same as debugging locally, but the console.log lives in Loki instead of your terminal.
Backups Are git stash for Your Data
git stash saves your working state so you can get back to it. Backups save your production state so you can get back to it.
The 3-2-1 rule: 3 copies of your data, on 2 different media, with 1 offsite. Your database on the server is copy 1. A nightly dump to a local volume is copy 2. That dump rsynced to an offsite machine (or object storage) is copy 3.
For PostgreSQL: pg_dump mydb > backup.sql. For Docker volumes: stop the container, tar the volume directory, copy it out. For files: rsync -avz /data/ offsite:/backups/data/.
The backup that matters is the one you’ve tested restoring. A pg_dump that you’ve never run psql mydb < backup.sql against is a file, not a backup. Test your restores. Schedule it. Automate it. The day you need a backup is not the day to discover it doesn’t work.
The Cheat Sheet
| DevOps Concept | Your Mental Model |
|---|---|
| Container | Process with its own filesystem |
| Dockerfile | Makefile for images |
| Docker Compose | package.json for services |
| Volume | Symlink that survives restarts |
| Port mapping | Binding a service to a socket |
| Reverse proxy | URL router for your server |
| DNS | Global key-value store |
| SSH | Remote REPL over encrypted tunnel |
| TLS | Authentication middleware for transport |
| CI/CD pipeline | Post-push git hook |
| Infrastructure as Code | git diff for servers |
| Cron | setInterval at the OS level |
| Systemd | Process manager / pm2 for Linux |
| Logs | console.log shipped to a database |
| Metrics | Performance counters scraped on an interval |
| Alerts | Assertions that page you when they fail |
| Backups | git stash for production data |
| Registry | npm registry for container images |
Where to Go From Here
If this clicked for you, the next step isn’t signing up for an AWS course. It’s taking one of your projects and containerizing it. Right now.
- Docker — install it, run
docker run -it alpine sh, poke around. It’s a Linux shell in a box. - Docker Compose — write a
docker-compose.ymlfor your app and its database. One file, one command, the whole stack runs. - Caddy — point a domain at your server and let Caddy handle TLS. Three lines of config.
- Woodpecker CI — self-hosted CI/CD that costs nothing. Push code, tests run, deploy happens.
- Ansible — write a playbook that sets up a fresh server from scratch. Run it twice. Confirm it’s idempotent.
- Loki + Grafana — ship your logs somewhere searchable. The first time you debug a production issue by querying instead of SSHing, you’ll never go back.
The barrier isn’t cloud certification or a six-figure infrastructure budget. It’s a $6 VPS, a domain name, and the willingness to run the commands yourself.
Now you know which commands to run.
Jason Walker is a senior staff engineer and solo builder with 27 years of experience. He’s building Loop Lock, the perfect A/V loop creator, and designing the standard of system design grammar. He runs his own infrastructure, owns his own stack, and writes about building things without the weight. Interested in the grammar? Email stonecassette@gmail.com with the subject “Interested in your system design grammar.” Follow the work at jsonwalker.com.