LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Zero Trust Architecture in Practice: Beyond the Buzzword

securityzero-trustnetworkingdevopshomelabtailscalecloudflare

“Never trust, always verify” sounds simple until you try to implement it. Zero Trust Architecture (ZTA) has been a security industry buzzword for over a decade, but the core idea is sound and increasingly achievable without enterprise budgets: stop treating your network perimeter as a security boundary, because it no longer exists.

Traditional security assumes everything inside the firewall is safe. A compromised laptop, a misconfigured service, or a malicious insider can move laterally across the entire internal network unchallenged. Zero Trust flips the model: every request is authenticated and authorized regardless of where it originates — inside the office, on VPN, or from a cloud function.

This guide is about actually building it, not just describing the philosophy.

The Problem with the Perimeter Model

The classic network security model looks like this: hard shell, soft interior. A firewall blocks inbound traffic. Everything behind it trusts everything else. Employees VPN in to join the trusted interior.

This model has fundamental problems:

Lateral movement: Once an attacker is inside (phishing, supply chain compromise, malicious insider), they can reach any internal service. The 2020 SolarWinds breach is a textbook example — attackers inside the perimeter moved freely for months.

VPN sprawl: VPN gives access to the entire network segment, not just the specific service an employee needs. A contractor who needs to access one internal app gets a route to everything.

Cloud and SaaS make “inside” meaningless: Your infrastructure spans AWS, GCP, on-prem, and a dozen SaaS tools. There is no single perimeter to defend.

Device trust is assumed, not verified: A corporate laptop on the VPN is trusted. The same laptop compromised by malware is also trusted.

Zero Trust replaces network-level trust with a continuous evaluation of three signals: identity (who are you?), device posture (is your device healthy?), and context (is this request normal for this identity at this time?).

The Five Pillars of Zero Trust

1. Identity as the new perimeter: Every user and service has a cryptographic identity. Authentication happens on every request, not once at login. Short-lived credentials (JWTs, mTLS certificates) replace long-lived passwords and session cookies.

2. Device trust: Devices must meet a security baseline to access sensitive resources. This means full-disk encryption, up-to-date OS patches, no jailbreak/rooting, and endpoint protection running. Devices that fall out of compliance lose access automatically.

3. Least-privilege access: Employees and services get exactly the permissions they need for their role — nothing more. A developer gets access to their team’s staging environment, not production. A microservice gets read access to one database table, not the entire cluster.

4. Micro-segmentation: Instead of one flat internal network, resources are isolated into small segments. Compromising one segment doesn’t grant access to others. Service-to-service communication is explicitly allowed or denied, never assumed.

5. Continuous verification: Trust is not a one-time decision. Access is re-evaluated continuously based on behavior, device posture, and risk signals. Anomalous access patterns trigger step-up authentication or revocation.

Practical Implementation: Homelab and Small Teams

You don’t need Zscaler or Palo Alto Prisma to run Zero Trust. The open-source and affordable tooling available today makes it accessible to anyone with a home lab or small team.

Layer 1: Replace Your VPN with Tailscale

Traditional VPNs give network-level access. Tailscale gives device-to-device connectivity based on WireGuard, controlled by an identity provider.

Install Tailscale on every node:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Linux
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --authkey tskey-auth-xxxxx

# macOS
brew install tailscale
sudo tailscale up

# Docker — for containerized services
docker run -d \
  --name tailscale \
  --network host \
  --cap-add NET_ADMIN \
  --cap-add SYS_MODULE \
  -v /dev/net/tun:/dev/net/tun \
  -v tailscale-state:/var/lib/tailscale \
  -e TS_AUTHKEY=tskey-auth-xxxxx \
  -e TS_HOSTNAME=my-service \
  tailscale/tailscale:latest

Every device gets a stable 100.x.x.x IP that follows it everywhere. No port forwarding, no dynamic DNS, no firewall rules.

Enable Tailscale ACLs (the real Zero Trust part):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// tailscale ACL policy — replace default "allow all" with explicit rules
{
  "tagOwners": {
    "tag:server":    ["autogroup:admin"],
    "tag:dev":       ["autogroup:admin"],
    "tag:monitored": ["autogroup:admin"]
  },

  "acls": [
    // Admins can reach everything
    {
      "action": "accept",
      "src": ["autogroup:admin"],
      "dst": ["*:*"]
    },
    // Developers can SSH into dev servers only
    {
      "action": "accept",
      "src": ["group:developers"],
      "dst": ["tag:dev:22"]
    },
    // Developers can access internal web services
    {
      "action": "accept",
      "src": ["group:developers"],
      "dst": ["tag:server:80", "tag:server:443", "tag:server:8080"]
    },
    // Monitoring systems can reach the metrics port on all tagged servers
    {
      "action": "accept",
      "src": ["tag:monitored"],
      "dst": ["tag:server:9090", "tag:server:9100"]
    },
    // CI/CD can deploy to servers
    {
      "action": "accept",
      "src": ["tag:ci"],
      "dst": ["tag:server:22"]
    }
    // Everything else is implicitly denied
  ],

  "ssh": [
    // Allow SSH from any device owned by the user to their own devices
    {
      "action": "accept",
      "src": ["autogroup:member"],
      "dst": ["autogroup:self"],
      "users": ["autogroup:nonroot"]
    },
    // Admins can SSH to any server
    {
      "action": "accept",
      "src": ["autogroup:admin"],
      "dst": ["tag:server"],
      "users": ["root", "ubuntu", "admin"]
    }
  ],

  "nodeAttrs": [
    // Require device posture checks for sensitive access
    {
      "target": ["tag:server"],
      "attr": ["mullvad:off"]
    }
  ]
}

With this policy, a compromised developer laptop cannot SSH into production servers. A compromised CI runner cannot reach the monitoring endpoints. The network-level access is explicitly scoped.

Layer 2: Application-Layer Access Control with Cloudflare Access

Tailscale handles infrastructure access. For web applications exposed to the internet (or your team), Cloudflare Access provides application-layer Zero Trust without a VPN at all.

This is the model Google uses internally with BeyondCorp: users authenticate to an identity-aware proxy, which checks their identity and device posture before forwarding the request to the backend. The backend never receives unauthenticated traffic.

Set up Cloudflare Tunnel and Access:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Install cloudflared
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
dpkg -i cloudflared.deb

# Authenticate and create a tunnel
cloudflared tunnel login
cloudflared tunnel create homelab-tunnel

# Configure the tunnel
cat > ~/.cloudflared/config.yml << 'EOF'
tunnel: <your-tunnel-id>
credentials-file: /root/.cloudflared/<your-tunnel-id>.json

ingress:
  - hostname: grafana.yourdomain.com
    service: http://localhost:3000
  - hostname: argocd.yourdomain.com
    service: http://localhost:8080
  - hostname: proxmox.yourdomain.com
    service: https://localhost:8006
    originRequest:
      noTLSVerify: true
  - service: http_status:404

EOF

# Run as a system service
cloudflared service install

Configure Cloudflare Access policy (via Cloudflare dashboard or Terraform):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# terraform/cloudflare_access.tf
resource "cloudflare_access_application" "grafana" {
  zone_id          = var.cloudflare_zone_id
  name             = "Grafana"
  domain           = "grafana.yourdomain.com"
  type             = "self_hosted"
  session_duration = "8h"

  # Require re-auth after 8 hours
  auto_redirect_to_identity = true
}

resource "cloudflare_access_policy" "grafana_team" {
  application_id = cloudflare_access_application.grafana.id
  zone_id        = var.cloudflare_zone_id
  name           = "Team members only"
  precedence     = 1
  decision       = "allow"

  include {
    # Allow your team's email domain
    email_domain = ["yourcompany.com"]
    # Or specific emails
    # email = ["alice@example.com", "bob@example.com"]
  }

  require {
    # Require GitHub group membership
    github {
      name                 = "yourcompany"
      teams                = ["sre-team"]
      identity_provider_id = cloudflare_access_identity_provider.github.id
    }
  }
}

resource "cloudflare_access_identity_provider" "github" {
  zone_id = var.cloudflare_zone_id
  name    = "GitHub"
  type    = "github"

  config {
    client_id     = var.github_oauth_client_id
    client_secret = var.github_oauth_client_secret
  }
}

# Service token for CI/CD systems that can't do browser auth
resource "cloudflare_access_service_token" "ci" {
  zone_id = var.cloudflare_zone_id
  name    = "GitHub Actions"
  min_days_for_renewal = 30
}

Now grafana.yourdomain.com is protected. Visiting it redirects to GitHub OAuth, validates team membership, then issues a short-lived JWT. The Grafana process never sees unauthenticated requests.

Validate the JWT in your application (for extra defense-in-depth):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// middleware/cloudflare_jwt.go
package middleware

import (
    "context"
    "fmt"
    "net/http"
    "strings"

    "github.com/lestrrat-go/jwx/v2/jwk"
    "github.com/lestrrat-go/jwx/v2/jwt"
)

const cfCertsURL = "https://<your-team>.cloudflareaccess.com/cdn-cgi/access/certs"

type CloudflareJWT struct {
    keySet jwk.Set
    teamDomain string
    audienceTag string
}

func NewCloudflareJWT(teamDomain, audienceTag string) (*CloudflareJWT, error) {
    keySet, err := jwk.Fetch(context.Background(), cfCertsURL)
    if err != nil {
        return nil, fmt.Errorf("fetching CF public keys: %w", err)
    }
    return &CloudflareJWT{keySet: keySet, teamDomain: teamDomain, audienceTag: audienceTag}, nil
}

func (c *CloudflareJWT) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Get JWT from header (CF Access injects Cf-Access-Jwt-Assertion)
        tokenStr := r.Header.Get("Cf-Access-Jwt-Assertion")
        if tokenStr == "" {
            http.Error(w, "missing Cloudflare Access token", http.StatusUnauthorized)
            return
        }

        token, err := jwt.Parse([]byte(tokenStr),
            jwt.WithKeySet(c.keySet),
            jwt.WithValidate(true),
            jwt.WithAudience(c.audienceTag),
        )
        if err != nil {
            http.Error(w, "invalid token", http.StatusUnauthorized)
            return
        }

        // Inject verified email into request context
        email, _ := token.Get("email")
        ctx := context.WithValue(r.Context(), "user_email", email)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Layer 3: mTLS Between Services

VPNs and identity-aware proxies handle human access. Service-to-service communication needs its own identity model. Mutual TLS (mTLS) gives every service a cryptographic identity: services prove who they are with a certificate, and verify the certificate of every service they call.

Self-managed PKI with step-ca:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Install step CLI and step-ca
wget https://dl.smallstep.com/gh-release/cli/gh-release-header/v0.27.0/step_linux_0.27.0_amd64.tar.gz
tar -xzf step_linux_0.27.0_amd64.tar.gz
sudo cp step_0.27.0/bin/step /usr/local/bin/

# Initialize a CA
step ca init \
  --name "MyOrg Internal CA" \
  --dns "ca.internal,localhost" \
  --address ":9000" \
  --provisioner "admin@myorg.com"

# Run step-ca as a service
cat > /etc/systemd/system/step-ca.service << 'EOF'
[Unit]
Description=step-ca
After=network.target

[Service]
User=step
ExecStart=/usr/local/bin/step-ca /etc/step-ca/config/ca.json
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

systemctl enable --now step-ca

Issue service certificates automatically:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Register the CA with the system trust store
step ca bootstrap --ca-url https://ca.internal:9000 \
  --fingerprint $(step certificate fingerprint /etc/step-ca/certs/root_ca.crt) \
  --install

# Issue a certificate for the API service (valid 24h, auto-renews)
step ca certificate api.internal api.crt api.key \
  --ca-url https://ca.internal:9000 \
  --san api.internal \
  --san localhost \
  --not-after 24h

# Auto-renew with step-daemon
step ca renew --daemon api.crt api.key &

Implement mTLS in Go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// pkg/mtls/server.go
package mtls

import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "net/http"
    "os"
)

type Config struct {
    CertFile   string
    KeyFile    string
    CACertFile string
    ServerName string
}

// NewServer creates an HTTP server that requires client certificates
func NewServer(addr string, cfg Config, handler http.Handler) (*http.Server, error) {
    caCert, err := os.ReadFile(cfg.CACertFile)
    if err != nil {
        return nil, fmt.Errorf("reading CA cert: %w", err)
    }

    caPool := x509.NewCertPool()
    if !caPool.AppendCertsFromPEM(caCert) {
        return nil, fmt.Errorf("failed to parse CA certificate")
    }

    cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
    if err != nil {
        return nil, fmt.Errorf("loading server cert: %w", err)
    }

    return &http.Server{
        Addr:    addr,
        Handler: handler,
        TLSConfig: &tls.Config{
            Certificates: []tls.Certificate{cert},
            ClientCAs:    caPool,
            // RequireAndVerifyClientCert enforces mTLS
            ClientAuth:   tls.RequireAndVerifyClientCert,
            MinVersion:   tls.VersionTLS13,
        },
    }, nil
}

// NewClient creates an HTTP client that presents a certificate
func NewClient(cfg Config) (*http.Client, error) {
    caCert, err := os.ReadFile(cfg.CACertFile)
    if err != nil {
        return nil, fmt.Errorf("reading CA cert: %w", err)
    }

    caPool := x509.NewCertPool()
    caPool.AppendCertsFromPEM(caCert)

    cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
    if err != nil {
        return nil, fmt.Errorf("loading client cert: %w", err)
    }

    return &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: &tls.Config{
                Certificates: []tls.Certificate{cert},
                RootCAs:      caPool,
                ServerName:   cfg.ServerName,
                MinVersion:   tls.VersionTLS13,
            },
        },
    }, nil
}

Extract service identity from the certificate in middleware:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// middleware/mtls_identity.go
func MTLSIdentity(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
            http.Error(w, "client certificate required", http.StatusUnauthorized)
            return
        }

        cert := r.TLS.PeerCertificates[0]
        // The service identity is the CN of the client certificate
        serviceID := cert.Subject.CommonName

        // SPIFFE URI SANs for SPIFFE-compatible PKI
        for _, uri := range cert.URIs {
            if strings.HasPrefix(uri.String(), "spiffe://") {
                serviceID = uri.String()
                break
            }
        }

        ctx := context.WithValue(r.Context(), "service_id", serviceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Layer 4: SPIFFE/SPIRE for Dynamic Service Identity

For Kubernetes and dynamic environments, manually issuing certificates per service doesn’t scale. SPIRE (the SPIFFE Runtime Environment) automates cryptographic service identity using the workload attestation API.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# kubernetes/spire-server.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: spire-server
  namespace: spire
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spire-server
  template:
    spec:
      containers:
        - name: spire-server
          image: ghcr.io/spiffe/spire-server:1.9.0
          args: ["-config", "/run/spire/config/server.conf"]
          volumeMounts:
            - name: spire-config
              mountPath: /run/spire/config
            - name: spire-data
              mountPath: /run/spire/data
      volumes:
        - name: spire-config
          configMap:
            name: spire-server-config
        - name: spire-data
          persistentVolumeClaim:
            claimName: spire-data
---
# SPIRE Agent DaemonSet — runs on every node to attest workloads
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: spire-agent
  namespace: spire
spec:
  selector:
    matchLabels:
      app: spire-agent
  template:
    spec:
      hostPID: true
      hostNetwork: true
      containers:
        - name: spire-agent
          image: ghcr.io/spiffe/spire-agent:1.9.0
          args: ["-config", "/run/spire/config/agent.conf"]
          volumeMounts:
            - name: spire-config
              mountPath: /run/spire/config
            - name: spire-agent-socket
              mountPath: /run/spire/sockets
            - name: spire-bundle
              mountPath: /run/spire/bundle

Once SPIRE is running, every pod gets a SPIFFE Verifiable Identity Document (SVID) — an X.509 certificate with a URI SAN like spiffe://myorg.com/ns/production/sa/api-service. Envoy sidecar proxies use these SVIDs to establish mTLS automatically between pods.

Register a workload:

1
2
3
4
5
6
# Tell SPIRE that pods with this service account get this SPIFFE ID
spire-server entry create \
  -spiffeID spiffe://myorg.com/ns/production/sa/api-service \
  -parentID spiffe://myorg.com/ns/spire/sa/spire-agent \
  -selector k8s:ns:production \
  -selector k8s:sa:api-service

Layer 5: Policy as Code with OPA

Open Policy Agent (OPA) decouples authorization policy from application code. Services query OPA to decide whether a request is allowed — the policy logic lives in a centrally managed, version-controlled repository.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# policies/api_access.rego
package api.access

import future.keywords.if
import future.keywords.in

# Default: deny everything
default allow := false

# Allow if the request passes all checks
allow if {
    valid_identity
    has_permission
    not rate_limited
    device_compliant
}

valid_identity if {
    # JWT must be present and not expired
    token := input.jwt
    payload := io.jwt.decode(token)[1]
    payload.exp > time.now_ns() / 1e9
    payload.iss == "https://accounts.yourdomain.com"
}

has_permission if {
    # Look up the user's role
    user := input.user
    role := data.roles[user]
    # Check the role has the required permission
    permission := input.required_permission
    permission in data.role_permissions[role]
}

device_compliant if {
    # Device must have passed posture check in last 24 hours
    device := input.device_id
    check_time := data.device_posture[device].last_check
    time.now_ns() - check_time < 86400 * 1e9
}

# Rate limiting: max 100 req/min per user
rate_limited if {
    count(data.recent_requests[input.user]) > 100
}

Deploy OPA as a sidecar in Kubernetes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# kubernetes/opa-sidecar.yaml
spec:
  containers:
    - name: api
      image: mycompany/api:latest
      env:
        - name: OPA_URL
          value: "http://localhost:8181"

    - name: opa
      image: openpolicyagent/opa:latest-rootless
      args:
        - "run"
        - "--server"
        - "--addr=localhost:8181"
        - "--bundle=/policies"
        - "--log-format=json"
      volumeMounts:
        - name: policies
          mountPath: /policies
          readOnly: true
  volumes:
    - name: policies
      configMap:
        name: opa-policies

Query OPA from your application:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// pkg/authz/opa.go
func (a *OPAAuthorizer) Authorize(ctx context.Context, input AuthzInput) (bool, error) {
    body, _ := json.Marshal(map[string]any{"input": input})

    resp, err := http.Post(
        "http://localhost:8181/v1/data/api/access/allow",
        "application/json",
        bytes.NewReader(body),
    )
    if err != nil {
        // Fail closed on OPA errors
        return false, err
    }
    defer resp.Body.Close()

    var result struct {
        Result bool `json:"result"`
    }
    json.NewDecoder(resp.Body).Decode(&result)
    return result.Result, nil
}

Device Posture Checking

Zero Trust requires that devices meet a security baseline. Tailscale can enforce this with device posture attributes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// tailscale ACL — require posture checks
{
  "posture": {
    "devicePosture": {
      "1": {
        "checks": {
          "lastSeen": {"within": "1h"},
          "osVersion": {
            "linux": [">=", "6.1"],
            "darwin": [">=", "14.0"],
            "windows": [">=", "10.0.19041"]
          }
        }
      }
    }
  },
  "acls": [
    {
      "action": "accept",
      "src": ["autogroup:member"],
      "dst": ["tag:sensitive:*"],
      "srcPosture": ["1"]   // Only devices meeting posture check 1
    }
  ]
}

For Cloudflare Access, the WARP client provides device posture:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Require WARP to be installed and enabled
resource "cloudflare_access_policy" "require_warp" {
  application_id = cloudflare_access_application.internal_app.id
  zone_id        = var.cloudflare_zone_id
  name           = "Require WARP and compliant device"
  precedence     = 1
  decision       = "allow"

  include {
    email_domain = ["yourcompany.com"]
  }

  require {
    device_posture = [cloudflare_device_posture_rule.require_warp.id]
    device_posture = [cloudflare_device_posture_rule.require_disk_encryption.id]
  }
}

resource "cloudflare_device_posture_rule" "require_disk_encryption" {
  zone_id = var.cloudflare_zone_id
  name    = "Disk Encryption Required"
  type    = "disk_encryption"
  input {
    requireAll = true
  }
}

Zero Trust for the Homelab

Even without a team, Zero Trust principles improve your homelab security significantly. Here’s a practical homelab setup:

Internet → Cloudflare Tunnel → Cloudflare Access (GitHub OAuth) → Internal services
Home devices → Tailscale → Internal services (via ACL)
Services ↔ Services → mTLS with step-ca

Full Docker Compose stack:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# docker-compose.yml
version: '3.8'

services:
  # Identity-aware tunnel to the internet
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --config /etc/cloudflared/config.yml run
    volumes:
      - ./config/cloudflared:/etc/cloudflared:ro
    restart: unless-stopped
    networks:
      - internal

  # Internal CA for service certificates
  step-ca:
    image: smallstep/step-ca:latest
    volumes:
      - step-ca-data:/home/step
    environment:
      DOCKER_STEPCA_INIT_NAME: Homelab CA
      DOCKER_STEPCA_INIT_DNS_NAMES: step-ca,localhost
      DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT: "true"
      DOCKER_STEPCA_INIT_ACME: "true"  # ACME protocol for auto cert issuance
    ports:
      - "127.0.0.1:9000:9000"  # Only accessible from localhost
    restart: unless-stopped
    networks:
      - internal

  # OPA for policy enforcement
  opa:
    image: openpolicyagent/opa:latest-rootless
    command:
      - run
      - --server
      - --addr=0.0.0.0:8181
      - --bundle=/policies
    volumes:
      - ./config/opa/policies:/policies:ro
    networks:
      - internal

  traefik:
    image: traefik:v3.0
    command:
      - --providers.docker=true
      - --providers.docker.exposedByDefault=false
      - --entryPoints.websecure.address=:443
      - --certificatesResolvers.step.acme.caServer=https://step-ca:9000/acme/acme/directory
      - --certificatesResolvers.step.acme.email=admin@homelab.local
      - --certificatesResolvers.step.acme.storage=/certs/acme.json
      - --certificatesResolvers.step.acme.tlsChallenge=true
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik-certs:/certs
    networks:
      - internal

  grafana:
    image: grafana/grafana:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.grafana.rule=Host(`grafana.homelab.local`)"
      - "traefik.http.routers.grafana.tls.certResolver=step"
    networks:
      - internal

networks:
  internal:
    driver: bridge

volumes:
  step-ca-data:
  traefik-certs:

Migrating from a Traditional Setup

Zero Trust is a journey, not a cutover. Here’s a practical migration path:

Phase 1 — Visibility (week 1-2)

  • Deploy Tailscale on all devices and servers
  • Enable logging on your firewall and reverse proxy
  • Map all service-to-service communication flows
  • Identify which services are exposed to the internet

Phase 2 — Identity Layer (week 3-4)

  • Stand up Cloudflare Access in front of all web-accessible services
  • Require SSO (GitHub, Google, Okta) — eliminate individual username/password access
  • Enable Tailscale ACLs in audit mode (log violations but don’t block)

Phase 3 — Enforce Least Privilege (month 2)

  • Convert Tailscale ACLs from audit to enforcement
  • Start with non-critical services; expand to sensitive systems
  • Revoke any shared credentials; replace with service accounts
  • Enable device posture requirements

Phase 4 — Service Identity (month 3)

  • Deploy step-ca or SPIRE
  • Issue mTLS certificates to internal services
  • Enable mTLS on one service pair at a time
  • Add OPA for fine-grained authorization where needed

Phase 5 — Continuous Improvement

  • Automate certificate rotation
  • Add anomaly detection and step-up auth
  • Quarterly access reviews — remove stale permissions

Common Pitfalls

Thinking Zero Trust means no network segmentation: Micro-segmentation and Zero Trust are complementary. The network should still enforce basic isolation; Zero Trust adds identity-layer enforcement on top.

Over-rotating to “deny everything” too fast: Implement in audit mode first. Surprising access paths are almost always legitimate ones you forgot about.

Treating the identity provider as invincible: Your IdP (Okta, Google, GitHub) is now the crown jewel. Enable MFA everywhere, use hardware security keys for privileged access, and have a break-glass procedure for IdP outages.

Ignoring service accounts: Most Zero Trust implementations focus on humans. Service accounts, CI/CD tokens, and automation credentials are often overlooked and become the easiest attack vector.

Neglecting egress: Zero Trust is usually applied to inbound access. But a compromised service can exfiltrate data outbound. Apply egress filtering and DNS monitoring too.

Conclusion

Zero Trust isn’t a product you buy — it’s a set of principles you implement incrementally. The core insight is simple: network location is not a proxy for trust. A device inside your firewall is not trustworthy by default. A device on the public internet with a valid identity and healthy posture can be trusted for specific operations.

The tooling — Tailscale, Cloudflare Access, step-ca, SPIRE, OPA — is open-source, well-documented, and affordable enough for a one-person homelab. Start by replacing your VPN with Tailscale and putting Cloudflare Access in front of your web services. Those two changes alone eliminate a huge class of threats that traditional perimeter security leaves open.

The rest follows naturally: once you’re thinking in terms of identity rather than network location, least-privilege access and service-to-service mTLS feel like the obvious next steps rather than ambitious security projects.

Comments