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.
./<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
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.
./data — that's the whole instance (repos + SQLite DB).SilverBullet — self-hosted notes
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
Vaultwarden — self-hosted passwords
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
./data backed up — it holds every secret.Nextcloud — self-hosted files & cloud
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.
NEXTCLOUD_TRUSTED_DOMAINS and serve it over HTTPS (see Caddy), or the sync clients will refuse to connect.Jellyfin — media server
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
/dev/dri (above) and enable VAAPI/QSV in Dashboard → Playback for near-zero-CPU streams.Portainer — Docker management UI
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)
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
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
WEBHOOK_URL to your real domain (via Caddy), or external services can't call back.Uptime-Kuma — monitoring & status pages
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
Caddy — reverse proxy + automatic HTTPS
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
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
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.
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 "/*"