“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