Single-VPS infrastructure-as-code for Ubuntu. Shell scripts provision the OS; Docker Compose runs all services behind Traefik.
Public services are served on port 443 (Let's Encrypt via Cloudflare DNS challenge). Private services are only reachable over Tailscale.
- Traefik — reverse proxy and TLS termination for every service; routes by Docker labels
- Docker Compose — one project per service, all on an external
proxynetwork - Tailscale — private overlay for sensitive services
- UFW — default-deny firewall
- SSH hardening — key-only, no root login
- Unattended-upgrades — automatic security updates
| Service | Containers | Hostname | Access |
|---|---|---|---|
| Website | site |
aryam.dev |
public |
| Website (dev) | site-dev |
dev.aryam.dev |
private |
| Audiobookshelf | abs, absg, readmeabook |
abs.aryam.dev, absg.aryam.dev, rmab.aryam.dev |
public |
| Filebrowser | files |
files.aryam.dev |
private |
| Prowlarr | prow |
prow.aryam.dev |
private |
| qBittorrent | gluetun, qbittorrent, mousehole |
qb.aryam.dev, mousehole.aryam.dev |
private |
| Homepage | homepage |
home.aryam.dev |
private |
| Dozzle (logs) | dozzle |
logs.aryam.dev |
private |
| Dockhand | dockhand |
dockhand.aryam.dev |
private |
| GoatCounter | goatcounter |
stats.aryam.dev |
private |
| Traefik dashboard | traf |
Tailscale entrypoint | private |
qBittorrent and Mousehole share Gluetun's network namespace, so all their traffic exits through NordVPN (WireGuard, Netherlands).
Compose files bind absolute /server paths, so clone there:
sudo git clone https://github.com/aryam1/server.git /server.env files are gitignored — create them before starting anything:
docker/traefik/.env (required — Traefik will not start without it)
CF_API_EMAIL=you@example.com # Cloudflare account email
CF_DNS_API_TOKEN=... # API token with DNS:Edit on aryam.dev
PUBLIC_IP=... # server's public IPv4
TAILSCALE_IP=... # server's Tailscale IP (after step 4)
docker/qbittorrent/.env
NORDVPN_PRIVATE_KEY=... # NordVPN WireGuard private key
MAM_COOKIE=... # MyAnonaMouse session cookie (mam_points.py)
docker/homepage/.env
HOMEPAGE_VAR_OWM_API_KEY=... # OpenWeatherMap API key
Also ensure the ACME storage exists and is only readable by root:
sudo touch /server/docker/traefik/data/acme.json
sudo chmod 600 /server/docker/traefik/data/acme.jsonsudo bash provision.shRuns, in order: install-packages → setup-docker → setup-tailscale → setup-ufw → setup-ssh.
sudo tailscale up # follow the auth link
tailscale ip -4 # put this in docker/traefik/.env as TAILSCALE_IPTraefik creates the external proxy network that every other service joins:
cd /server/docker/traefik && sudo docker compose up -d
for dir in /server/docker/*/; do (cd "$dir" && sudo docker compose up -d); donedocker ps
sudo ufw status
tailscale statusCheck https://aryam.dev publicly and any private hostname from a tailnet device.
Once Tailscale SSH works, close port 22 entirely (see scripts/setup-ufw.sh output):
sudo ufw delete allow 22/tcp && sudo ufw reload
sudo systemctl disable --now ssh- Create
docker/<name>/docker-compose.ymljoining the externalproxynetwork. - Add Traefik labels:
labels:
- traefik.enable=true
- traefik.http.routers.<name>.rule=Host(`<name>.aryam.dev`)
- traefik.http.routers.<name>.entrypoints=private # or public
- traefik.http.routers.<name>.tls.certresolver=cf # private entrypoint only
- traefik.http.services.<name>.loadbalancer.server.port=<container-port>sudo docker compose up -dfrom the new directory.
Note: if you omit the rule label, Traefik's default rule assigns <compose-service-name>.aryam.dev.
- All services join the external
proxynetwork defined by Traefik's compose file. exposedByDefault: false— every container needs explicittraefik.enable=trueand router labels.- Services never publish host ports; only Traefik listens on the host:
80(redirect to HTTPS),443onPUBLIC_IP(public entrypoint),443onTAILSCALE_IP→ container8443(private entrypoint), and41641/udpfor Tailscale. - Private entrypoint is firewalled to
100.64.0.0/10(Tailscale CGNAT range) — it is not reachable from the internet.
| Port | Purpose |
|---|---|
| 80 | HTTP, redirects to HTTPS |
| 443 | HTTPS (public, bound to public IP) |
| 443 → 8443 | HTTPS private entrypoint (bound to Tailscale IP only) |
| 41641/udp | Tailscale |
| 22 | SSH (hardened, should be closed after Tailscale SSH works) |
server/
├── provision.sh # Main orchestration script (run as root)
├── scripts/ # Individual setup steps called by provision.sh
│ ├── install-packages.sh
│ ├── setup-docker.sh
│ ├── setup-tailscale.sh
│ ├── setup-ufw.sh
│ └── setup-ssh.sh
├── configs/
│ └── ssh/sshd_config # Hardened SSH configuration
├── websites/ # Static portfolio served by the site container
└── docker/ # One compose project per service
├── traefik/ # .env, data/traefik.yml, data/acme.json, logs/
├── site/ # nginx:alpine, default.conf (prod), nocache.conf (dev)
├── audiobooks/ # Audiobookshelf, green proxy, ReadMeABook
├── filebrowser/
├── prowlarr/
├── qbittorrent/ # gluetun + qbittorrent + mousehole
├── homepage/
├── dozzle/
├── dockhand/
└── goatcounter/
The site container serves /server/websites read-only and proxies /count and /count.js to GoatCounter, allowlisting only specific ref query values (see docker/site/default.conf).
cd /server/docker/<service>
sudo docker compose pull && sudo docker compose up -dDozzle at logs.aryam.dev, or:
sudo docker logs -f traf
sudo docker logs -f <container>Handled entirely by Traefik's Cloudflare DNS-challenge resolver (cf) — no certbot. Certs live in docker/traefik/data/acme.json and renew automatically.
# Traefik state (certificates) — root-only file
sudo tar -czf ~/backup-$(date +%s).tar.gz /server/docker/traefik/data/acme.json /etc/ssh
# Service state
tar -czf ~/data-backup-$(date +%s).tar.gz /server/docker/*/config /server/docker/*/data /mnt/storageboxA service is unreachable
- Confirm the container is on the
proxynetwork and hastraefik.enable=trueplus matching router/service labels. - Wrong cert resolver or missing entrypoint label is the usual cause.
Traefik won't start
- Requires
docker/traefik/.envwithCF_API_EMAIL,CF_DNS_API_TOKEN,PUBLIC_IP,TAILSCALE_IP. - Ensure
data/acme.jsonexists with mode600.
Certificate errors
- Check
sudo docker logs traf. The Cloudflare token needsDNS:Editonaryam.dev. - Do not run certbot — it would fight Traefik for port 80/443.
New service can't start
- Start Traefik first; the
proxynetwork must already exist.
Firewall blocking Tailscale
- Private services require
ufw allow from 100.64.0.0/10 to any port 8443 proto tcp.
- UFW default-deny incoming (allow: 22, 80, 443, 41641/udp, 8443 from Tailscale range)
- SSH key-only, root login disabled, PAM disabled
- Private entrypoint restricted to Tailscale CGNAT range
- qBittorrent traffic forced through NordVPN via Gluetun
- All secrets live in gitignored
.envfiles - Container images pinned to
latest— update deliberately withdocker compose pull