TL;DR: the server that sits at home and runs my game servers, backups and a few tools I use every day. Terraform manages Cloudflare and Tailscale, Docker Compose manages every service, and nothing is reachable without going through one of the two.
| Machine | VAIO laptop (VJFE59F11X) |
| CPU | AMD Ryzen 7 5700U, 8 cores / 16 threads |
| Memory | 16 GB |
| Storage | 512 GB NVMe SSD |
| OS | Ubuntu 26.04 LTS |
| Backups | Cloudflare R2 bucket, where Restic keeps the snapshots |
Limited hardware forces some creativity. Restic deduplicates every snapshot, so backups every 20 minutes stay inside R2's free tier, and services are picked for doing one thing well on little memory: most containers here use less than 64 MB.
| Path | Description |
|---|---|
| Internet | Users access services through Cloudflare. Cloudflare checks the user's email and sends a one-time code to verify them. Once verified, traffic goes through the tunnel into the server, where NGINX sends each request to the right service. A few specific paths skip the email check, like the Wakapi API that my GitHub profile reads from, and the Gatus status page is public. |
| Tailscale | Devices connected to the Tailscale network can access services directly with randomized ports. The server only allows certain ports for laptops and phones, and shared users can only reach some services. |
Infrastructure as Code.
Everything that can be codified, is. Terraform manages DNS, the tunnel, access policies and network ACLs, and Docker Compose defines every service. There are no SSH-and-pray moments.
Defense in depth.
Public traffic only enters through a Cloudflare Tunnel with Cloudflare Access in front of it, private traffic goes through Tailscale with ACLs, and host-exposed services use non-default ports. Each layer holds on its own.
Predictable under load.
Containers are grouped as infrastructure, applications and sandboxes, and each has CPU and memory limits so one workload cannot starve the others.
| Β Β Β Β Β | Service | Used for |
|---|---|---|
| cloudflared | Connects the server to Cloudflare's edge network, routing public traffic through a secure tunnel so services are accessible without exposing the server directly | |
| nginx | Acts as a reverse proxy, reading the incoming domain and forwarding each request to the correct internal service | |
| gatus | Checks every running service every minute and shows which ones are up on a public status page | |
| btop | Exposes a real-time system monitor through the browser so you can check CPU, memory, and processes without SSH | |
| filebrowser | Provides a web-based file manager to browse, upload, and edit files across the entire server | |
| ofelia | Runs scheduled jobs inside containers using Docker labels, used here to trigger backups automatically | |
| rclone | Runs as a REST server that receives and stores backup data from Restic | |
| restic | Backs up the Satisfactory saves, the Wakapi and ai-memory data, Hermes' data, Lemon's sandbox and the Terraform state to the Rclone server on a schedule | |
| upsnap | Wakes my main PC with Wake-on-LAN, since it lives outside the lab, and shows which machines on the network are online |
| Β Β Β Β Β | Service | Used for |
|---|---|---|
| satisfactory | Runs a dedicated Satisfactory game server that friends can join at any time, with automatic backups and configurable player limits | |
| palworld | Runs a dedicated Palworld server for friends, with its saves backed up automatically | |
| dst | Runs a Don't Starve Together dedicated server cluster (Master + Caves shards) that friends can join over Tailscale | |
| ai-memory | Runs a shared long-term memory server that coding agents (Claude Code, OpenCode, ...) across multiple machines connect to over MCP, so context and handoffs persist between sessions and agents | |
| teamspeak | Runs a self-hosted TeamSpeak 6 voice server so friends can join a private voice channel over Tailscale | |
| wakapi | Collects coding activity from the editors and terminals on my machines, a self-hosted alternative to WakaTime that also feeds my GitHub profile. Separate wakapi-pepper, wakapi-mint and wakapi-lemon jobs report each agent's Hermes activity to its own Wakapi user every 5 minutes |
|
| ytdlp | Provides a web interface for yt-dlp to download videos from various platforms directly to the server | |
| kamiyomu | Runs a self-hosted, extensible manga reader that discovers, downloads, and organizes manga from various sources into a personal library | |
| kavita | Serves a polished web-based reader over the manga KamiYomu downloads, with library management, metadata, and per-user reading progress |
| Β Β Β Β Β | Sandbox | Used for |
|---|---|---|
| lemon-sandbox | Gives Lemon a persistent SSH workspace for multiple Roblox repositories, with each project's tools installed through Rokit |
services/
βββ {infra|apps}/
βββ <service-name>/
βββ configuration/ # Static config files mounted into the container
βββ .env # Local environment values (gitignored)
βββ .env.example # Documented variables with placeholder values
βββ compose.yml # Service definition with ports, volumes, networks, resources
sandboxes/
βββ compose.yml # Includes each sandbox
βββ <sandbox-name>/
βββ .env # Local environment values (gitignored)
βββ .env.example # Documented variables with placeholder values
βββ compose.yml # Sandbox image, storage, network and limits
βββ README.md # Setup and runtime mounts
Static config files live in a configuration/ subdirectory, so there is a single place to look for a service's settings.
# services/infra/nginx/compose.yml
volumes:
- ./configuration/nginx.conf.template:/etc/nginx/templates/nginx.conf.template:roPrefixes avoid collisions and make it clear what each variable controls.
# services/apps/satisfactory/.env.example
SATISFACTORY_MAX_PLAYERS=4
SATISFACTORY_GAME_PORT=7328
SATISFACTORY_MESSAGING_PORT=1273Infrastructure keeps the server running and observable; applications are hosted tools and games. Sandboxes are persistent development environments used by agents.
services/
βββ infra/ # monitoring, networking, backups, access
β βββ btop/
β βββ cloudflared/
β βββ dozzle/
β βββ filebrowser/
β βββ nginx/
β βββ ofelia/
β βββ rclone/
β βββ restic/
β βββ upsnap/
βββ apps/ # game servers and tools
βββ dst/
βββ kamiyomu/
βββ kavita/
βββ palworld/
βββ satisfactory/
βββ teamspeak/
βββ wakapi/
βββ ytdlp/
sandboxes/ # agent development environments
βββ compose.yml
βββ lemon-sandbox/
# services/infra/dozzle/compose.yml
deploy:
resources:
limits:
cpus: '0.5'
memory: 64MOnly the port exposed on the host is randomized; internal ports stay at their well-known defaults.
# services/infra/btop/compose.yml
ports:
- "${BTOP_PORT}:7681"Services and sandboxes resolve each other by container name on a shared Docker network. The exception is UpSnap, which needs the host network to send Wake-on-LAN packets.
# shared pattern across all compose.yml files
networks:
lab:
name: lab
external: trueContainers restart on their own after a crash or a reboot, and their names match the Compose service key.
# shared pattern across all compose.yml files
restart: unless-stopped
container_name: <service-name>Ubuntu |
Docker |
Terraform |
Cloudflare |
Tailscale |
NGINX |
GitHub Actions |
What's already in place:
- β Every service in Docker Compose, split into infra and apps, each with CPU and memory limits
- β Tailscale ACLs and device tags, managed with Terraform
- β A Cloudflare Tunnel behind Access, replacing ngrok
- β btop in the browser, replacing Glances
- β UpSnap, to wake my main PC over Wake-on-LAN
- β Game servers for friends: Satisfactory, Palworld and Don't Starve Together
- β A TeamSpeak 6 voice server, over Tailscale
- β Tailscale Services for the TCP-only ports
- β Terraform validation on every PR, and a pre-commit hook
- β A manga library: KamiYomu to download, Kavita to read
- β Hermes in a container of its own, running Pepper
- β Wakapi for my coding time across machines, with a user for Pepper
- β ai-memory, a long-term memory shared by every coding agent I use
- β Container limits sized from measured peaks
- β Restic backups to a private R2 bucket
- β Gatus, a public status page checking every service, replacing Dozzle
- Docker and Docker Compose
- Terraform β₯ 1.0
- A Cloudflare account with a zone configured
- A Tailscale tailnet with devices enrolled
# 1. Install the local tooling
make install
# 2. Create the shared Docker network
make setup
# 3. Bootstrap Terraform
make terraform-init
# 4. Review and apply infrastructure changes
make terraform-plan
make terraform-apply
# 5. Start infrastructure services
make infra-up
# 6. Start application services
make apps-up
# 7. Start prepared agent sandboxes
make sandboxes-up| Command | Description |
|---|---|
install |
Install pipx and the pre-commit hooks |
setup |
Create the lab Docker network |
infra-up |
Start all infrastructure services |
infra-up-build |
Start infra services with rebuild |
infra-down |
Stop all infrastructure services |
apps-up |
Start all application services |
apps-up-build |
Start app services with rebuild |
apps-down |
Stop all application services |
sandboxes-up |
Start all prepared agent sandboxes |
sandboxes-down |
Stop all agent sandboxes |
terraform-init |
Initialize Terraform |
terraform-init-upgrade |
Re-initialize with provider upgrades |
terraform-plan |
Preview infrastructure changes |
terraform-apply |
Apply infrastructure changes |