< cd ..

Tutorials

tutorials/

Copy-paste guides for standing up real services. Every code block has a Copy button — grab the whole thing in one click.

Self-hosting with Docker Compose

All the Docker Compose guides below assume Docker Engine + the Compose plugin on a Linux host. Install once:

# Docker Engine + Compose plugin (Debian/Ubuntu)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER   # log out/in so the group applies
docker compose version

Then, for each service: make a folder, drop in the compose.yml, and run docker compose up -d. Data lives in a bind-mounted folder next to the file, so backups are trivial.

Convention used here: one folder per service, data under ./<service>, and restart: unless-stopped so it survives reboots. Put a reverse proxy (see Caddy) in front to get a clean domain + HTTPS.

Gitea — self-hosted Git

image: gitea/gitea · web on :3000 · ssh on :222

A lightweight self-hosted Git service — repos, issues, PRs, CI — that runs comfortably on a Raspberry Pi. This uses SQLite so it's a single container with no database to manage.

1. Create the folder and compose.yml:

mkdir -p ~/gitea && cd ~/gitea
# ~/gitea/compose.yml
services:
  gitea:
    image: gitea/gitea:1.22
    container_name: gitea
    restart: unless-stopped
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=sqlite3
      - GITEA__server__SSH_PORT=222
      # For a real domain behind a reverse proxy, also set:
      # - GITEA__server__DOMAIN=git.example.com
      # - GITEA__server__ROOT_URL=https://git.example.com/
    volumes:
      - ./data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "222:22"

2. Start it:

docker compose up -d
docker compose logs -f gitea   # watch it boot

3. Open http://SERVER_IP:3000 and finish the web installer. The first user you register becomes the admin. To clone over SSH later, use port 222: git clone ssh://git@SERVER_IP:222/user/repo.git.

Tip: back up by stopping the container and copying ./data — that's the whole instance (repos + SQLite DB).

SilverBullet — self-hosted notes

image: zefhemel/silverbullet · web on :3000

A fast, Markdown-based personal knowledge base. Every page is a plain .md file on disk, so your notes are yours — grep them, version them with Git, sync them anywhere.

1. Folder + compose.yml (basic-auth protected):

mkdir -p ~/silverbullet && cd ~/silverbullet
# ~/silverbullet/compose.yml
services:
  silverbullet:
    image: zefhemel/silverbullet:latest
    container_name: silverbullet
    restart: unless-stopped
    environment:
      # user:password for the built-in login. Change it!
      - SB_USER=admin:change-me-please
    volumes:
      - ./space:/space
    ports:
      - "3033:3000"

2. Start it and open the notebook:

docker compose up -d
# then browse to  http://SERVER_IP:3033  (log in with SB_USER)

3. Your notes live in ./space as Markdown. Want automatic backups? Turn that folder into a Git repo and let a tiny sidecar commit + push on a timer:

cd ~/silverbullet/space
git init && git add -A && git commit -m "initial notes"
git remote add origin ssh://git@SERVER_IP:222/you/notes.git
git push -u origin main
Why it's nice: no proprietary format, no lock-in. If the container disappears, your knowledge base is just a folder of Markdown you can open in any editor.

Vaultwarden — self-hosted passwords

image: vaultwarden/server · web on :8080

A lightweight, Bitwarden-compatible password server written in Rust. Use the official Bitwarden apps/extensions and point them at your own instance.

1. Generate an admin token (for the /admin panel):

openssl rand -base64 48

2. Folder + compose.yml (paste the token into ADMIN_TOKEN):

mkdir -p ~/vaultwarden && cd ~/vaultwarden
# ~/vaultwarden/compose.yml
services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    environment:
      - SIGNUPS_ALLOWED=false           # invite-only after your first account
      - ADMIN_TOKEN=PASTE_THE_TOKEN_HERE
      # Behind a reverse proxy with a real domain:
      # - DOMAIN=https://vault.example.com
    volumes:
      - ./data:/data
    ports:
      - "8080:80"

3. Start it, create your account, then lock down sign-ups:

docker compose up -d
# 1) open http://SERVER_IP:8080 and register YOUR account first
# 2) SIGNUPS_ALLOWED=false above keeps it invite-only afterwards
Security: Vaultwarden must sit behind HTTPS in real use (the browser extensions require it). Put Caddy in front for automatic TLS, and keep ./data backed up — it holds every secret.

Nextcloud — self-hosted files & cloud

image: nextcloud + mariadb · web on :8080

Your own Drive/Dropbox: files, calendar, contacts, photos, and collaborative docs — with desktop and mobile sync clients. This runs Nextcloud with a MariaDB database (the production-recommended setup).

1. Folder + compose.yml (change every change-me):

mkdir -p ~/nextcloud && cd ~/nextcloud
# ~/nextcloud/compose.yml
services:
  nextcloud-db:
    image: mariadb:11
    container_name: nextcloud-db
    restart: unless-stopped
    command: --transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW
    environment:
      - MYSQL_ROOT_PASSWORD=change-me-root
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud
      - MYSQL_PASSWORD=change-me-db
    volumes:
      - ./db:/var/lib/mysql

  nextcloud:
    image: nextcloud:apache
    container_name: nextcloud
    restart: unless-stopped
    depends_on: [nextcloud-db]
    environment:
      - MYSQL_HOST=nextcloud-db
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud
      - MYSQL_PASSWORD=change-me-db
      - NEXTCLOUD_ADMIN_USER=admin
      - NEXTCLOUD_ADMIN_PASSWORD=change-me-admin
      # Behind a reverse proxy, whitelist the domain:
      # - NEXTCLOUD_TRUSTED_DOMAINS=cloud.example.com
    volumes:
      - ./data:/var/www/html
    ports:
      - "8080:80"

2. Start it (the DB needs a few seconds before Nextcloud finishes install):

docker compose up -d
docker compose logs -f nextcloud   # wait for "initialization finished"

3. Open http://SERVER_IP:8080 and log in with the admin user/password from the compose file. Install the desktop/mobile clients and point them at that URL.

Behind a proxy: Nextcloud is picky about its domain — set NEXTCLOUD_TRUSTED_DOMAINS and serve it over HTTPS (see Caddy), or the sync clients will refuse to connect.

Jellyfin — media server

image: jellyfin/jellyfin · web on :8096

A free, open-source media server for the movies, shows and music you own — with apps for TV, phone and browser. No subscription, no tracking.

1. Folder + compose.yml (point the last volume at your media):

mkdir -p ~/jellyfin && cd ~/jellyfin
# ~/jellyfin/compose.yml
services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    restart: unless-stopped
    volumes:
      - ./config:/config
      - ./cache:/cache
      - /srv/media:/media:ro          # your library, read-only
    ports:
      - "8096:8096"
    # Hardware transcoding (Intel QSV / VAAPI): uncomment and adjust
    # devices:
    #   - /dev/dri:/dev/dri

2. Start it and run the setup wizard:

docker compose up -d
# open http://SERVER_IP:8096 → create admin → add your media libraries
Transcoding tip: direct-play is free; on-the-fly transcoding is CPU-hungry. If your box has an Intel iGPU, pass /dev/dri (above) and enable VAAPI/QSV in Dashboard → Playback for near-zero-CPU streams.

Portainer — Docker management UI

image: portainer/portainer-ce · web on :9000

A web UI to see and manage every container, image, volume and network on your host — logs, stats, shells, and one-click stack deploys. Great for spotting what's running (and what's leaking memory).

1. Folder + compose.yml:

mkdir -p ~/portainer && cd ~/portainer
# ~/portainer/compose.yml
services:
  portainer:
    image: portainer/portainer-ce:latest
    container_name: portainer
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data:/data
    ports:
      - "9000:9000"

2. Start it and set the admin password quickly:

docker compose up -d
# open http://SERVER_IP:9000 within a few minutes and create the admin user
# (Portainer locks first-user setup after a timeout for security)
Heads up: mounting docker.sock gives Portainer full control of the host's Docker — that's how it works, but treat access to this UI like root. Don't expose it to the internet without auth + HTTPS.

n8n — workflow automation

image: n8nio/n8n · web on :5678

A self-hosted, fair-code alternative to Zapier/Make: build automations visually — HTTP calls, webhooks, cron, hundreds of integrations, and code nodes when you need them. Your data and workflows stay on your box.

1. Folder + compose.yml:

mkdir -p ~/n8n && cd ~/n8n
# ~/n8n/compose.yml
services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    container_name: n8n
    restart: unless-stopped
    environment:
      - GENERIC_TIMEZONE=America/Santiago
      - N8N_PORT=5678
      # For webhooks to work behind a reverse proxy, set your public URL:
      # - N8N_HOST=n8n.example.com
      # - WEBHOOK_URL=https://n8n.example.com/
    volumes:
      - ./data:/home/node/.n8n
    ports:
      - "5678:5678"

2. Start it and open the editor:

docker compose up -d
# open http://SERVER_IP:5678 → create the owner account → build your first workflow
Webhooks: if a workflow uses webhooks, n8n needs to know its public URL — set WEBHOOK_URL to your real domain (via Caddy), or external services can't call back.

Uptime-Kuma — monitoring & status pages

image: louislam/uptime-kuma · web on :3001

A self-hosted monitoring tool: HTTP/TCP/ping/DNS checks, response-time graphs, public status pages, and alerts to Telegram/Discord/email/webhooks.

1. Folder + compose.yml:

mkdir -p ~/uptime-kuma && cd ~/uptime-kuma
# ~/uptime-kuma/compose.yml
services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    restart: unless-stopped
    volumes:
      - ./data:/app/data
    ports:
      - "3001:3001"

2. Start it and create your admin account in the browser:

docker compose up -d
# open http://SERVER_IP:3001  → set admin user → add your first monitor
Nice touch: add a monitor for every service in this list, then publish a single status page. It's the difference between "I think it's up" and knowing.

Caddy — reverse proxy + automatic HTTPS

image: caddy · :80 / :443 · TLS on autopilot

Instead of remembering ports (:3000, :3033, :8080…), give each service a clean subdomain with HTTPS. Caddy fetches and renews Let's Encrypt certificates automatically — no cron, no certbot.

1. Put the services and Caddy on one shared Docker network so Caddy can reach them by name:

docker network create web   # run once; add `networks: [web]` to each service

2. The Caddyfile — one block per service (point your DNS A records at this host first):

# ~/caddy/Caddyfile
git.example.com {
    reverse_proxy gitea:3000
}
notes.example.com {
    reverse_proxy silverbullet:3000
}
vault.example.com {
    reverse_proxy vaultwarden:80
}
status.example.com {
    reverse_proxy uptime-kuma:3001
}

3. The Caddy compose.yml:

# ~/caddy/compose.yml
services:
  caddy:
    image: caddy:2
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./data:/data
      - ./config:/config
    networks: [web]

networks:
  web:
    external: true
docker compose up -d
docker compose logs -f caddy   # watch it issue certificates
No public IPv4? (CGNAT): Let's Encrypt's HTTP challenge won't reach you. Use tls internal in each block and access over a Tailscale mesh instead — same clean domains, no port-forwarding.

Deploy a static site on AWS with Terraform

aws · terraform · S3 + CloudFront + ACM + Route53

The exact setup behind this site: a private S3 bucket served through CloudFront (never public), a free auto-renewing ACM certificate, and a custom domain on Route53 — all as reusable code.

What you'll build: S3 (private, OAC) → CloudFront (HTTPS) → ACM (cert) → Route53 (DNS), with Terraform state in S3 + a DynamoDB lock.

1 · Bootstrap remote state (once)

# bootstrap/state.tf
resource "aws_s3_bucket" "tf_state" { bucket = "myproj-terraform-state" }
resource "aws_dynamodb_table" "tf_lock" {
  name = "terraform-locks"  billing_mode = "PAY_PER_REQUEST"  hash_key = "LockID"
  attribute { name = "LockID"  type = "S" }
}

2 · Backend + providers

terraform {
  backend "s3" {
    bucket = "myproj-terraform-state"  key = "site/terraform.tfstate"
    region = "us-west-2"  dynamodb_table = "terraform-locks"  encrypt = true
  }
}
provider "aws" { region = var.aws_region }
provider "aws" { alias = "us_east_1"  region = "us-east-1" }  # CloudFront cert lives here

3 · Private bucket + CloudFront (OAC)

resource "aws_s3_bucket" "site" { bucket = var.site_bucket_name }
resource "aws_s3_bucket_public_access_block" "site" {
  bucket = aws_s3_bucket.site.id
  block_public_acls = true   block_public_policy = true
  ignore_public_acls = true  restrict_public_buckets = true
}
resource "aws_cloudfront_origin_access_control" "oac" {
  name = "myproj-oac"  origin_access_control_origin_type = "s3"
  signing_behavior = "always"  signing_protocol = "sigv4"
}

4 · Ship it

terraform init      # connects to remote state
terraform plan      # read this before applying
terraform apply     # creates S3, CloudFront, ACM, Route53
aws s3 sync ./public s3://example-com-site --delete
aws cloudfront create-invalidation --distribution-id <ID> --paths "/*"
Why private + OAC? The bucket is never publicly reachable, you get HTTPS + HTTP/2 + a global CDN for free, and the origin is readable only through your distribution — the difference between a demo and prod.

< back to the CV