Hi, I'm Brett

Marketing guru by day, FOSS dev by night.

Stop Exposing Your Homelab: Build a Private Edge with Tailscale, Swizzin, and Docker


At some point, your setup stops being “a server” and starts being a system.

That’s usually when things get weird.

  • Docker networking starts fighting you
  • Reverse proxies turn into glue instead of structure
  • Half your services live on the host, the other half in containers
  • Everything works, but only if you don’t touch it

I hit that point running a hybrid Swizzin + Docker stack.

So I stopped trying to fix Docker.

And replaced the network instead.


The problem nobody talks about

Most people build stacks like this:

internet → nginx → apps → storage

Then spend the rest of their time:

  • hardening NGINX
  • blocking bots
  • tuning fail2ban
  • pretending that’s normal

It works.

But it’s backwards.

You’re building a public service… just to use it privately.


Move the boundary

Instead of defending the edge, move it.

Everything sits behind Tailscale.

tailscale → nginx → local services

No public ingress. No exposed ports. No background noise.

You don’t protect your server from the internet.

You just don’t publish it.


The hybrid setup that actually works

This is not a “Docker everything” post.

Some workloads want the metal:

  • torrent clients
  • disk-heavy operations
  • anything that hammers IO

That stays on Swizzin.

Everything else goes into containers:

  • Sonarr / Radarr (plus 4K splits)
  • Prowlarr
  • Jellyseerr
  • PostgreSQL
  • NZBGet
  • automation glue

The split is simple:

Metal for throughput. Containers for control.


Where it usually falls apart

Hybrid setups break at the network layer.

  • Docker uses bridges
  • Host services expect direct access
  • Reverse proxies sit awkwardly in between

You end up debugging things like:

“Why can this container not reach that service unless I do something cursed?”

And yeah — you can make it work.

But it’s fragile.


Tailscale becomes the network

Instead of fixing Docker networking, I stopped relying on it.

Everything binds to Tailscale.

  • host services
  • reverse proxy
  • remote access
  • internal routing

Now everything lives on one flat network.

No NAT. No guessing. No weird interfaces.

If it’s on the network, it works.


NGINX is not public anymore

This is the part that actually changes things.

NGINX does not listen on 0.0.0.0.

It binds only to the private interface.

# only reachable over tailscale
listen 100.x.x.x:443 ssl;
listen [fd7a:...]:443 ssl;

server_name media.internal;

That means:

  • no public 80/443
  • no scanners
  • no bot traffic
  • no constant garbage

NGINX becomes a private ingress router, not a public web server.


The clean service model

Everything behind NGINX is local-only.

From the Docker side:

services:
  sonarr:
    ports:
      - "127.0.0.1:8989:8989"

  radarr:
    ports:
      - "127.0.0.1:7878:7878"

And from the routing side:

location /sonarr {
    proxy_pass http://127.0.0.1:8989;
}

location /radarr {
    proxy_pass http://127.0.0.1:7878;
}

So the flow becomes:

tailscale → nginx → 127.0.0.1 → container

Apps don’t know the internet exists.

They don’t need to.


This is where it clicks

                    tailscale
                        │
                        ▼
                     nginx
                        │
        ┌───────────────┼───────────────┐
        │               │               │
     swizzin         docker         database
   (bare metal)       stack         (shared)
        │               │
        └─────── shared storage ───────┘
                   (/data)

This is the first time the system actually feels stable.


This isn’t just access. It’s an edge.

Most people use Tailscale for access.

This uses it as a boundary.

Tailscale controls who connects
NGINX controls how things are served

And NGINX is not stock.


Treating NGINX like a real edge

NGINX is built properly:

zstd on;
brotli on;
gzip on;

Along with:

  • HTTP/2 + HTTP/3 + QUIC
  • async IO
  • proper buffer tuning

Compression isn’t an afterthought.

It’s layered:

  • prefer zstd
  • fall back to brotli
  • fall back to gzip
  • disable for bad clients

This is closer to CDN behavior than most homelabs ever get.


Caching where it matters

proxy_cache_path /var/cache/nginx/sonarr keys_zone=sonarr_cache:10m;

location /sonarr/MediaCover/ {
    proxy_cache sonarr_cache;
    proxy_cache_valid 200 30d;
}

You cache:

  • media assets
  • UI bundles
  • static resources

With:

  • cache locking
  • stale updates
  • auth-aware bypass

Not “turn on cache and hope.”

Deliberate.


IO tuned like it matters

Under the hood:

  • async file reads
  • direct IO for large files
  • open file caching
  • tuned buffers

The goal:

Serve fast without blocking.

Even internally, this matters more than people think.


You accidentally build a private CDN

client → tailscale → nginx (edge)
                    ├─ compression
                    ├─ caching
                    ├─ routing
                    ▼
                 services

No Cloudflare. No exposure.

Still fast.

Still clean.


The part most people miss

Tailscale isn’t just networking.

It’s policy.

Access isn’t:

“you’re on the network, good luck”

It’s defined.

Here’s a simplified version:

{
  "acls": [
    {
      "action": "accept",
      "src": ["iphone"],
      "dst": ["autogroup:internet:*"]
    },
    {
      "action": "accept",
      "src": ["automation"],
      "dst": ["media:443", "media:22"]
    }
  ]
}

Different devices get different access.

Your phone is not your server.

Automation nodes don’t get full control.


It gets better: posture checks

You can validate what the device is:

"postures": {
  "default": [
    "node:os in ['macos','ios','windows']",
    "node:tsReleaseTrack == 'stable'"
  ]
}

Now access depends on:

  • OS
  • client version
  • device type

Not just “is it connected?”


SSH without the nonsense

"ssh": [
  {
    "action": "check",
    "src": ["autogroup:member"],
    "dst": ["autogroup:self"],
    "users": ["root", "user"],
    "checkPeriod": "48h"
  }
]

No key distribution.

No drift.

Identity-based SSH, built into the network.


Mental model

infra:
  swizzin + docker + storage

routing:
  nginx

control:
  tailscale

Each layer has a job.

Nothing overlaps.

Nothing fights.


And this is why Swizzin adding Tailscale matters

There’s active work bringing native Tailscale support into Swizzin.

This is the part people are sleeping on.

Right now, this setup takes intent:

  • install tailscale manually
  • bind interfaces correctly
  • structure nginx properly

When this lands?

This becomes the default sane way to run a box.

  • private by default
  • clean networking
  • hybrid setups just work
  • no public exposure required

This is a big shift.


What you actually get

Not features.

Outcomes.

Performance where it matters

Bare metal where it counts.

Flexibility where it matters

Containers for everything else.

Security by default

Nothing exposed unless you choose it.

Simplicity under pressure

One network. One model.

No weirdness.


Final thought

Most people scale by adding layers.

More proxies. More rules. More complexity.

This goes the other way.

Remove the internet.
Flatten the network.
Treat your server like a private edge node.

Let:

  • Swizzin handle the metal
  • Docker handle the apps
  • Tailscale handle the network

And everything gets quieter.