Clone My Server

A field guide to one bare-metal box that runs everything: dev environments with automatic public subdomains, git-push production deploys, a monitoring stack that pages an AI agent, and a terminal workspace where a fleet of coding agents builds software in parallel. Everything below is reproducible; snippets are sanitized copies of what actually runs.

0Philosophy

Three ideas hold this whole setup together:

1The machine

A Scaleway bare-metal server (their Elastic Metal line — any dedicated-server vendor works) running stock Ubuntu 24.04 LTS. This one has an AMD EPYC 12-core / 24-thread CPU, 128 GB RAM and ~1 TB of NVMe in software RAID. That's massively overprovisioned for web serving — the headroom is for agents: a dozen concurrent coding agents with their builds, test runs and dev servers eat CPU and RAM in bursts.

Directory layout — the backbone convention

~/workspace/<project>/   # dev checkouts — every folder here gets a *.dev.<domain> URL
~/deploy/<project>/      # prod checkouts — deployed by git hooks, run by systemd
~/git/<project>.git      # bare repos — the push target that triggers deploys
~/tools/<tool>/          # small operational tools (bridges, monitors)

One regular user (member of sudo and docker) owns everything. System-level services (Caddy, BIND, monitoring containers) are the only root-adjacent things.

2Caddy: reverse proxy & automatic TLS

Caddy terminates TLS for every hostname and reverse-proxies to local ports. It's the only thing listening on 80/443. Why Caddy: automatic certificates (including on-demand issuance for wildcard subdomains, no DNS plugin needed) and a config file simple enough to be generated by a shell script — which is exactly what happens (§3).

The config has three layers:

  1. Global options — ACME email and an on_demand_tls ask-endpoint (a local Caddy vhost that just answers 200, so any subdomain that reaches the box gets a cert).
  2. import /etc/caddy/sites.d/*.caddyfile — hand-written vhosts for anything special (static sites, apex domains, custom headers). One file per site.
  3. Two generated wildcard blocks*.dev.<domain> and *.<domain>, rewritten every minute by devproxy-sync. Never edited by hand.

The reverse-proxy block pattern

@myapp host myapp.dev.cosmictaco.dev
handle @myapp {
    reverse_proxy localhost:5100 {
        flush_interval -1          # stream immediately — REQUIRED for SSE / AI token streams
        transport http {
            dial_timeout 2s        # dead backend fails fast instead of hanging 30s
        }
    }
}
💡 flush_interval -1 is load-bearing. Without it Caddy buffers responses and every Server-Sent-Events stream (i.e. every streaming AI app) appears frozen. Put it in every proxy block by default.

Static site pattern (sites.d)

# /etc/caddy/sites.d/landing.caddyfile
landing.cosmictaco.dev {
    root * /var/www/landing
    file_server
    encode gzip
}

Unmatched subdomains fall through to a static fallback page (root * /var/www/fallback + try_files /index.html), so a typo'd or stopped project shows a friendly page instead of a connection error.

3devproxy-sync — folders become URLsthe killer feature

The single highest-leverage script on the box. A cron job scans the two project directories every minute and regenerates the wildcard Caddy blocks:

Create a folder, and one minute later it has a public HTTPS URL. This matters more than it sounds: agents can spin up a project and hand you a shareable link with zero proxy configuration, from a phone you can check any dev server, and webhooks/OAuth callbacks work against real HTTPS URLs in dev.

Per-project port config: .devproxy.json

File contentsResulting routes
{ "port": 5100 } myapp.dev.cosmictaco.dev → localhost:5100
{ "web": 5140, "api": 5141 } myapp-web.dev… → :5140
myapp-api.dev… → :5141
(no file) dev → :3000, prod → :3001
💡 Port convention: every project gets its own decade of ports starting at 5100 — project A owns 5100–5109, project B owns 5140–5149, monitoring owns 5180–5189, and so on. Multi-app projects number within their decade. No collisions, and a port number instantly tells you which project it belongs to.

The script

Full sanitized copy — installed at /usr/local/bin/devproxy-sync, run by cron every minute (* * * * * /usr/local/bin/devproxy-sync). It writes to a temp file and only touches Caddy when the config actually changed, so the cron is free when nothing moved. Run it manually with sudo devproxy-sync to skip the wait.

#!/bin/bash
# devproxy-sync: generate Caddyfile for dev + prod subdomains
#
# Dev:  ~/workspace/<project> → <project>.dev.cosmictaco.dev (port 3000 default)
# Prod: ~/deploy/<project>   → <project>.cosmictaco.dev (via .devproxy.json or 3001)
#
# .devproxy.json format:
#   { "port": 3939 }                    → <project>:3939
#   { "web": 3939, "api": 4000 }        → <project>-web:3939, <project>-api:4000
#   No file                             → dev:3000, prod:3001

WORKSPACE="/home/youruser/workspace"
DEPLOYDIR="/home/youruser/deploy"
CADDYFILE="/etc/caddy/Caddyfile"
FALLBACK="/var/www/fallback"
DEV_DOMAIN="dev.cosmictaco.dev"
PROD_DOMAIN="cosmictaco.dev"
TMPFILE=$(mktemp)

# ── Helper: scan a directory and collect entries ──
scan_dir() {
    local basedir="$1"
    local default_port="$2"
    local entries=""

    for dir in "$basedir"/*/; do
        [ -d "$dir" ] || continue
        project=$(basename "$dir")
        config="$dir/.devproxy.json"

        if [ -f "$config" ]; then
            simple_port=$(grep -oP '"port"\s*:\s*\K\d+' "$config" 2>/dev/null)
            if [ -n "$simple_port" ]; then
                entries="$entries $project:$simple_port"
            else
                while IFS="=" read -r app port; do
                    app=$(echo "$app" | tr -d " \"")
                    port=$(echo "$port" | tr -d " \",}")
                    [ -z "$app" ] || [ -z "$port" ] && continue
                    entries="$entries ${project}-${app}:$port"
                done < <(grep -oP "\"[^\"]+\"\s*:\s*\d+" "$config" | sed "s/\"\([^\"]*\)\"\s*:\s*\(.*\)/\1=\2/")
            fi
        else
            entries="$entries $project:$default_port"
        fi
    done

    echo "$entries"
}

# ── Helper: write a wildcard block ──
write_block() {
    local domain="$1"
    local entries="$2"

    cat >> "$TMPFILE" << EOF

*.${domain} {
    tls {
        on_demand
    }

EOF

    if [ -n "$entries" ]; then
        for entry in $entries; do
            subdomain="${entry%%:*}"
            port="${entry##*:}"
            matcher="${subdomain//[-.]/_}_$(echo "$domain" | tr '.' '_')"
            cat >> "$TMPFILE" << EOF
    @${matcher} host ${subdomain}.${domain}
    handle @${matcher} {
        reverse_proxy localhost:${port} {
            flush_interval -1
            transport http {
                dial_timeout 2s
            }
        }
    }

EOF
        done
    fi

    cat >> "$TMPFILE" << EOF
    handle {
        root * ${FALLBACK}
        try_files /index.html
        file_server
    }

    handle_errors {
        root * ${FALLBACK}
        rewrite * /index.html
        file_server
    }
}
EOF
}

# ── Global config ──
cat > "$TMPFILE" << HEADER
{
    email you@example.com
    on_demand_tls {
        ask http://localhost:5555
    }
}

http://localhost:5555 {
    respond 200
}

import /etc/caddy/sites.d/*.caddyfile

HEADER

# ── Scan directories ──
DEV_ENTRIES=$(scan_dir "$WORKSPACE" 3000)
PROD_ENTRIES=$(scan_dir "$DEPLOYDIR" 3001)

# ── Write blocks ──
write_block "$DEV_DOMAIN" "$DEV_ENTRIES"
write_block "$PROD_DOMAIN" "$PROD_ENTRIES"

# ── Reload if changed ──
if ! diff -q "$TMPFILE" "$CADDYFILE" > /dev/null 2>&1; then
    sudo cp "$TMPFILE" "$CADDYFILE"
    if systemctl is-active --quiet caddy; then
        sudo systemctl reload caddy 2>/dev/null
    else
        sudo systemctl start caddy 2>/dev/null
    fi
fi

rm -f "$TMPFILE"
⚠️ Note: the on-demand-TLS ask-endpoint above approves every hostname that reaches the box. That's a deliberate convenience trade-off on a personal server whose DNS you fully control; if strangers can point DNS at your IP, make the ask-endpoint check the hostname against your domains instead of blanket-200ing.

4DNS, VPN & firewall

DNS

Two wildcard A records are all the routing needs: *.dev.<domain> → server IP and *.<domain> → server IP. Any DNS host works (this setup has used a self-hosted BIND as well as managed providers — wildcards + short TTLs are the only requirement). If you self-host BIND, the box runs named as just another systemd service; keep the zone files in git.

Tailscale — the admin plane

Tailscale (WireGuard mesh VPN) connects the server, laptop and phone. Everything administrative rides the VPN: SSH, database clients, internal dashboards. The public internet only ever sees ports 80/443.

Firewall posture (UFW)

💡 The point: there is no password or SSH key that helps an attacker who isn't inside the VPN. This also means agents on the box can be given broad local permissions without exposing an attack surface to the internet.

5Deploys: git push → live

Deployment is a bare git repo plus a hook. No CI service, no registry, no containers for app code.

# one-time setup per project
git init --bare ~/git/myapp.git
# then in your checkout:
git remote add deploy ~/git/myapp.git     # or user@host:git/myapp.git from a laptop
git push deploy main                      # ← this is the whole deploy interface

The post-receive hook

~/git/myapp.git/hooks/post-receive checks out the pushed commit into ~/deploy/myapp, builds, copies the env file, restarts the service, and logs everything to ~/deploy/myapp.deploy.log:

#!/bin/bash
set -euo pipefail

APP="myapp"
DEPLOY_DIR="/home/youruser/deploy/$APP"
SERVICE="$APP.service"
LOG="/home/youruser/deploy/$APP.deploy.log"

echo "=== Deploy $APP started at $(date) ===" | tee "$LOG"

# Only deploy on main branch
while read oldrev newrev refname; do
    branch=$(git rev-parse --symbolic --abbrev-ref "$refname" 2>/dev/null || echo "$refname")
    if [ "$branch" != "main" ] && [ "$refname" != "refs/heads/main" ]; then
        echo "Push to $branch — skipping deploy" | tee -a "$LOG"
        exit 0
    fi
done

mkdir -p "$DEPLOY_DIR"
GIT_WORK_TREE="$DEPLOY_DIR" git checkout -f main 2>&1 | tee -a "$LOG"
cd "$DEPLOY_DIR"

bun install --frozen-lockfile 2>&1 | tee -a "$LOG"
bun run build 2>&1 | tee -a "$LOG"

# env files live OUTSIDE the repo and are copied in at deploy time
cp "/home/youruser/secrets/$APP.env.local" "$DEPLOY_DIR/.env.local"

sudo systemctl restart "$SERVICE" 2>&1 | tee -a "$LOG"
echo "=== Deploy $APP finished at $(date) ===" | tee -a "$LOG"

systemd: one simple unit per app…

# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network.target

[Service]
Type=simple
User=youruser
WorkingDirectory=/home/youruser/deploy/myapp
ExecStart=/home/youruser/.bun/bin/bun run start
Restart=always
RestartSec=5
KillMode=control-group
TimeoutStopSec=10
Environment=NODE_ENV=production
Environment=PORT=3001

[Install]
WantedBy=multi-user.target

…and a template unit for a monorepo fleet

The big app on this box is a Bun + Turborepo Next.js monorepo whose prod runs as a fleet of ~15 processes (web, API gateways, a dozen queue workers). One systemd template unit covers them all — %i is the package name:

# /etc/systemd/system/fleet-prod@.service
[Unit]
Description=Prod fleet — %i
After=network-online.target
Wants=network-online.target
PartOf=fleet-prod.target

[Service]
Type=simple
User=youruser
WorkingDirectory=/home/youruser/deploy/monorepo
EnvironmentFile=/home/youruser/deploy/monorepo/.env.local
Environment=PATH=/home/youruser/.bun/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/youruser/.bun/bin/bun run --filter=%i start
Restart=always
RestartSec=5
KillMode=control-group
TimeoutStopSec=20

[Install]
WantedBy=fleet-prod.target
sudo systemctl enable --now fleet-prod@api fleet-prod@worker-mail fleet-prod@worker-jobs
systemctl list-units 'fleet-prod@*'        # the whole fleet at a glance
💡 EnvironmentFile is load-bearing: not every package loads dotenv itself. Feeding the deploy dir's .env.local through systemd means every fleet member gets the same env without each package needing its own loader. (Also: env files never live in git — they sit in the deploy dir and survive checkouts because the hook copies them back in.)

The deploy-bot pane

Deploys are never fired from a background shell. A dedicated agent pane (an AI agent session named "deploy bot" in the workspace manager, §8) runs them: it pushes, tails the deploy log, curls the health endpoints, and reports. If a deploy goes sideways there's a visible, scrollable terminal with the whole story — and an agent with context already loaded to fix it. Alerts are piped into the same pane (§7).

Next evolution (in progress): blue/green — deploy to a second port, health-check, then flip the proxy target, for zero-downtime fleet restarts.

6Data services

Databases run as Docker containers with remapped host ports, one shared instance per engine for all dev work:

docker run -d --name app-postgres --restart unless-stopped \
  -p 127.0.0.1:5440:5432 \
  -e POSTGRES_PASSWORD=<YOUR_PASSWORD> \
  pgvector/pgvector:pg16

docker run -d --name app-redis --restart unless-stopped \
  -p 127.0.0.1:6390:6379 \
  redis:7-alpine

7Monitoring & alerts — that page an agent

A small Prometheus-compatible stack runs as Docker containers on the monitoring port decade, every component bound to 127.0.0.1:

ComponentPortRole
VictoriaMetrics5180scrape + storage + PromQL, single binary (lighter than Prometheus)
vmalert5181evaluates alert rules
Alertmanager5182routes/deduplicates/emails alerts
blackbox exporter5183HTTP probes of the public URLs (the outside view)
⚠️ Host-network gotcha: the app fleet runs on the host (systemd), not in Docker. Bridged monitoring containers can't reach host 127.0.0.1 ports through a UFW-firewalled gateway — every scrape times out. Run the monitoring containers with network_mode: host and have each component bind its own 127.0.0.1:<port> explicitly. (With host networking, ports: mappings are silently ignored.)
⚠️ SMTP gotcha: Scaleway (like most server vendors) blocks outbound 587/465. Alert email goes out via Resend's SMTP relay on port 2587 — an alternate submission port they provide precisely for this. If your alert emails silently vanish, this is why.

Watching the watchers

The alert → agent bridge

The interesting part. A small script polls Alertmanager and injects newly-firing alerts into the deploy-bot agent pane together with a runbook prompt. The AI agent — which already has the deploy context, log locations and health-check commands in its head — investigates autonomously: snapshot forensics first, then remediation (restart the wedged unit, verify health), then an incident report. Two design rules learned the hard way:

8herdr — the terminal workspace managerthe control plane

herdr is a terminal workspace manager built for running AI agents: workspaces → tabs → panes, like tmux, but every pane knows whether the agent inside it is idle, working, blocked or done, and the whole thing is drivable over a socket CLI. The user connects with herdr --remote from a Mac (and mobile); every agent session on the server lives in a herdr pane. That one property — agent status as a queryable API — is what makes multi-agent orchestration (§10) possible.

The CLI surface

herdr workspace list
herdr workspace create --cwd ~/workspace/myapp --label "myapp" --no-focus
herdr workspace focus <id>   |   herdr workspace close <id>

herdr pane list [--workspace <id>]      # every pane + agent_status + cwd
herdr pane read <pane_id> [--lines N]   # read a pane's screen (spy on another agent)
herdr pane run <pane_id> '<command>'    # run a command in a pane
herdr pane send-text <pane_id> '<text>' # type text (single argv, quotes-safe)
herdr pane send-keys <pane_id> Enter    # send raw keys

herdr wait output <pane_id> --match "<text>" --timeout 30000
herdr wait agent-status <pane_id> --status idle|working|blocked|done --timeout 600000

Load-bearing gotchas (all learned live)

9AI agent setup

Three coding agents are installed side by side, and all three report status to herdr:

AgentUnattended launch"Turn finished" status
Claude Codeclaude --permission-mode autoidle
Codex CLIcodex -a never -s workspace-writedone
omp (Oh My Pi)omp --approval-mode yolodone
💡 Permission-mode nuance: for Claude Code use --permission-mode auto, not acceptEdits — the latter still blocks on the first shell command, which deadlocks an unattended pane. Different models have different strengths; having all three installed lets each task go to the best tool (§10 uses omp for backend legwork and Claude for frontend and orchestration).

Context discovery — write it once, every agent reads it

Memory

Agents keep a persistent memory directory (~/.claude/…/memory/): one file per fact with a one-line description in frontmatter, plus a MEMORY.md index that gets loaded each session. Corrections the user makes once ("never default subagents to the cheap model") become memory files and stop being repeated mistakes.

Skills

Reusable procedures live as skills — ~/.claude/skills/<name>/ (user-global) and <repo>/.claude/skills/<name>/ (per-repo) — small markdown playbooks the agent loads on demand: "set up devproxy for a project", "spawn a worktree agent", "run the agent factory" (§10). The pattern that works: skills encode proven multi-step recipes with their gotchas, not general knowledge the model already has.

10The agent factorythe crown jewel

The setup above compounds into this: a pattern for building a batch of features in parallel with a three-tier hierarchy of agents, proven over multiple live runs.

dispatcher (1 session — your pane)
│   plans waves, spawns orchestrators, merges PRs, GC's worktrees
├── orchestrator A (claude pane, own git worktree + branch + herdr workspace)
│   ├── worker: omp pane        — backend legwork
│   └── worker: claude pane     — frontend
├── orchestrator B ( … )
└── orchestrator C ( … )

Tier responsibilities

The rules that make it work

What this buys: a batch of five features lands in an afternoon with one human reviewing PRs and making merge calls, and every intermediate step is visible as a scrollable terminal pane rather than an opaque job log.

11Quality of life