Move your AI stack off cloud servers. This post shows how to migrate a production Sovereign AI blog and MCP server to a €163/year VPS, harden it, and run it with Docker and Caddy, complete with real configs and pitfalls.

Floki-VPS Setup for Sovereign AI Workloads

New to self-hosting AI? The Self-Hosted AI: Start Here hub walks the hardware-decision tree, inference-engine choice, and the operational gotchas that bite hardest in the first three months. Read it before or after this one, whichever fits your stage.

You’re running an AI stack on someone else’s hardware and you’re tired of the bill. You want full control, no vendor lock-in, and a machine that answers to you. Here’s how to move a working Sovereign AI blog and MCP server to a €163/year VPS without losing sleep.

As of 2026-05-04: pricing tier and stack revisions verified. The VPS II tier with 2 vCPU / 4 GB RAM remains the right floor for static blog plus MCP plus Caddy. If FlokiNET adjusts pricing or the VPS II spec, re-check before signing up.

Quick Take

  • Migrate a live Sovereign AI blog from an ARM64 dev box to an x86_64 VPS with zero downtime.
  • Harden SSH, UFW, fail2ban, and auto-updates so the box survives the internet.
  • Serve HTTPS with Caddy, rate-limit MCP endpoints, and log North Star Metrics to JSON files.
  • Rebuild and redeploy with a single tar pipe from your build machine.

SSH in with One Command

First, get on the box without typing a password every time.

Host floki
    HostName <public-ip>
    User <your-user>
    Port 22
    IdentityFile ~/.ssh/id_ed25519_floki
    IdentitiesOnly yes

Run ssh floki and you’re in. The private key never leaves your dev machine; the public key was uploaded when you ordered the VPS.

In practice, if you forget IdentitiesOnly yes, SSH may silently prompt for a password even though your key is loaded.

Lock Down SSH and UFW

Public IPs attract bots. Harden SSH and block everything except what you need.

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 900
ClientAliveCountMax 0
X11Forwarding no
AllowUsers <your-user>

Enable UFW with a default deny policy and allow only SSH, HTTP, and HTTPS.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

In practice, forgetting to run ufw enable leaves the firewall rules loaded but inactive.

Run Docker Without sudo

Install Docker CE 29.4.1 and the Compose plugin from Docker’s repo, not Debian’s.

sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker <your-user>

Log out and back in, then run docker ps without sudo.

In practice, if you skip the group add, every Docker command will fail with permission errors.

Migrate the Sovereign Blog with a Tar Pipe

Build the blog on your dev box, then stream it to the VPS.

cd /path/to/sovereign-blog && npm run build
tar czf - -C /path/to/sovereign-blog dist Dockerfile nginx.conf | \
  ssh <your-user>@<public-ip> 'tar xzf - -C ~/sovereign-blog/'

On the VPS, start the HTTPS stack with Caddy and Let’s Encrypt.

name: sovereign-blog
services:
  blog:
    build: .
    container_name: sovereign-blog
    expose:
      - "4321"
    volumes:
      - ./dist:/usr/share/nginx/html:ro
    restart: unless-stopped
  caddy:
    image: caddy:2-alpine
    container_name: sovereign-caddy
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    restart: unless-stopped
    depends_on:
      - blog
volumes:
  caddy_data:
  caddy_config:
sovgrid.org, www.sovgrid.org {
    reverse_proxy blog:4321
    encode gzip zstd
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        Referrer-Policy strict-origin-when-cross-origin
        Permissions-Policy "geolocation=(), camera=(), microphone=()"
    }
    @static { path *.css *.js *.svg *.woff2 *.webp }
    header @static Cache-Control "public, max-age=31536000, immutable"
}

In practice, if you forget the @static block, static assets won’t get the long cache headers.

Build a Custom Caddy with Rate Limiting

The official Caddy image doesn’t include the ratelimit plugin, so you build it yourself.

FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/mholt/caddy-ratelimit
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

Tag it as sovereign-blog-caddy and use it in your compose file to rate-limit MCP endpoints.

In practice, the custom build adds four minutes to your deploy pipeline, so keep the image cached.

Run the MCP Server Inside the Same Network

The MCP server runs in a separate compose stack but shares the Caddy network.

name: sovereign-mcp
services:
  mcp:
    build: .
    container_name: sovereign-mcp
    expose:
      - "8002"
    restart: unless-stopped

Caddy fronts it with a rate limit of 60 requests per minute per IP.

mcp.sovgrid.org {
    reverse_proxy mcp:8002
    rate_limit 60
}

In practice, if you expose the port directly on the host, you lose the convenience of a single HTTPS entry point.

Log North Star Metrics to JSON

Caddy writes access logs to ~/sovereign-blog/logs/mcp.log with JSON lines. FastMCP writes stdout to Docker logs. Together they give you the raw material for your North Star Metric.

docker logs sovereign-mcp

In practice, if you rely only on Caddy logs, you miss per-tool execution counts that appear in FastMCP stdout.

Fail2ban for Caddy Rate Limits

Add a jail to block repeat offenders hitting the 429 responses.

[Definition]
failregex = ^.*"remote_ip"\s*:\s*"<HOST>".*"status":429.*$

[caddy-mcp]
enabled = true
filter = caddy-mcp
logpath = /home/<your-user>/sovereign-blog/logs/mcp.log
maxretry = 5
bantime = 1h

In practice, if you don’t set logpath correctly, fail2ban will silently ignore the file.

What I Actually Use

  • Debian 13 Trixie on FlokiNET VPS II: the cheapest x86_64 box I could find that still feels like a server.
  • Caddy 2 with the ratelimit plugin: one binary, one config file, automatic HTTPS and rate limiting built in.
  • Docker Compose with external networks: keeps the blog and MCP server isolated but reachable through a single reverse proxy.

Operational sharpness that came after the first month live

Three things turned out to need attention beyond the initial setup steps.

Caddy’s automatic Let’s Encrypt renewal is supposed to be invisible. It mostly is, but the first renewal at 60 days post-install required a service restart that did not happen automatically because Caddy was running under a non-default systemd unit. The fix is making sure the unit has Restart=always plus a ReloadSignal=SIGUSR1 so renewal-triggered reloads happen without intervention. After that the renewal cycle has been silent across multiple cert lifetimes.

UFW’s default-deny posture conflicts with Docker’s iptables manipulation in a subtle way: Docker bypasses UFW for container-to-container traffic, which is usually fine, but means UFW logs do not show internal-network anomalies. The mitigation is either iptables-legacy mode and merging the rule sets, or accepting that Docker network traffic is observed at the daemon-log level rather than at UFW. The Floki setup uses the second approach because the simplification is worth more than the unified log.

The tar-pipe migration in the post works well for first-time deploys. For ongoing updates the rsync-based deploy.sh in the blog repo replaced it; the tar-pipe is now reserved for full-system snapshot restoration scenarios, not per-deploy use. Worth knowing if you copy the post’s commands and wonder why the day-to-day workflow looks different from what’s documented here.

Cost-wise, the no-KYC FlokiNET tier sits in the low tens of euros per month for the resource profile this stack needs (2 vCPU, 4 GB RAM, sufficient for static-blog serving plus Caddy plus the lightweight MCP-server reverse-proxy). The tradeoff against a similar-spec hyperscaler instance is privacy plus jurisdiction, traded against slightly higher latency from Western Europe to North American visitors. For a content site whose audience is global and whose latency budget is dominated by client-side render time anyway, the tradeoff is favorable.

Where to next

If this is your entry into the broader stack, the Self-Hosted AI: Start Here hub is the orientation post that explains what the VPS plays inside the larger sovereign-AI architecture (the heavy inference runs on local GB10 hardware, the VPS is the public-facing thin edge).

For the Cloudflare-tunnel-to-Caddy migration story that preceded this setup (and the failure modes that pushed me toward owning the public surface end-to-end), the cloudflared-to-Astro migration post is the prequel.

For the disaster-recovery side (what gets backed up off the VPS, where the Floki-snapshot cron lives, how to rebuild this exact box from cold), the backup system rebuild post covers the full pipeline including the Step 0 Floki-pull that ships with the current backup script.

Stack

Floki-VPS Tech Stack

Sovereign AI workload deployment architecture

6
AI Stack MCP server + blog
5
Web Server Caddy HTTPS proxy
4
Runtime Docker CE 29.4.1
3
Security SSH, UFW, fail2ban
2
OS Debian Linux hardened
1
Hardware €163/year x86_64 VPS