Everything in this series so far has been reachable only from my own network. Certificates for names that resolve nowhere public, forward auth behind an allowlist that drops anything from outside my LAN. That is a comfortable place to stop, and I did not stop there, for a reason that has less to do with remote access than you might expect.

The plan for this stack is OIDC between authentik and some apps. Forward auth is the fallback for applications that cannot do better, and a good number of the things I run can: they speak OpenID Connect properly and would redirect a user to an identity provider instead of having a proxy assert a header on their behalf. That is the better integration and it is where this is going.

OIDC only works if the identity provider is reachable. The browser being redirected has to reach it, and depending on the flow so does the application doing a token exchange. The moment any of that involves something outside my house, an identity provider that only answers on 192.168.66.0/24 becomes a local curiosity.

So auth had to become public. This post is what that cost, what I put in front of it, and why the access policy covers far less of the identity provider than you would expect.

Choosing a tunnel over a port forward

The obvious way to make something on your network reachable is to forward a port on your router. It works, it is free, and most people do it first.

A Cloudflare tunnel inverts the direction. Instead of opening a port and waiting for the internet to connect inward, a daemon on your machine dials outward to Cloudflare and holds those connections open. Requests arriving at Cloudflare’s edge for your hostname get handed down the connection that is already established. There is no inbound rule, no forwarded port, and your public address is never in DNS.

Your WAN address stops being a target. Nothing about the arrangement advertises where you live on the internet, and a scan of your address finds a closed port. If your address changes, nothing breaks, because nothing was pointing at it.

The listening socket is on Cloudflare’s side. Anything hostile arrives at their edge and is subject to whatever they do about it before it becomes your problem. That moves real risk off my network. It also creates a dependency, which I come back to at the end.

On a home connection the deciding property is that a tunnel works when your ISP does not cooperate. My inbound port 80 is blocked, which is why my certificate setup looks the way it does. A tunnel does not care. It is an outbound connection like any other.

Protection a homelab cannot build

Start with the physics. If somebody points a denial of service attack at a home connection, no amount of software on your side helps. Your firewall can drop every packet perfectly and you are still offline, because the packets already used up the link before your firewall saw them. The saturation happens on the segment between your ISP and your house, and there is nothing you can install on the far end of a full pipe that unfills it. Filtering has to happen upstream of the bottleneck or it does not count.

Cloudflare’s layer 3 and 4 mitigation absorbs DDoS attacks across its anycast network, with no per-gigabyte charge for attack traffic, on every plan including the free one. A tunnel sharpens it further: with no forwarded port and no public record pointing at my address, there is no origin to aim at in the first place. The attack surface becomes a network built to absorb attacks.

The rest is smaller, and the free tier gives you a slice of it:

ProtectionOn the free plan
Layer 3 and 4 DDoS mitigationUnmetered, always on, no configuration
Managed WAF rulesetA basic ruleset only, full OWASP coverage is paid
Custom firewall rulesFive
Rate limiting rulesOne, and unmetered since 2022
Bot filteringBot Fight Mode, the more capable modes are paid
TLS termination and certificate managementIncluded
Analytics retention24 hours

Bot Fight Mode challenges traffic that is obviously automated. A public hostname does not have to be interesting to attract constant scanning; it only has to exist. Knocking that noise down before it reaches the tunnel means my logs describe my traffic, which does more for seeing real problems than for stopping any particular one.

These protections cover what goes through Cloudflare and nothing else. Anything you expose by another route, a forwarded port on a different hostname, a service someone opened for a weekend and forgot, sits outside all of it.

Cloudflare terminates TLS. Requests are decrypted at the edge, inspected, and re-encrypted down the tunnel. That is how the WAF can inspect anything at all, and it means Cloudflare can see plaintext for everything passing through, including credentials submitted to my login page. Choosing this is choosing a party to trust with those credentials.

Twenty-four hours of analytics retention is thin for investigating anything. By the time you notice a pattern, the evidence for it may already have aged out. (That is within Cloudflare itself, and I may write about another approach in this ever-growing series.)

None of that changes the calculation for a homelab. It does narrow the claim: what Cloudflare gives me for nothing is the class of protection I could not build myself, plus a useful fraction of the class I could.

Running cloudflared

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  cloudflared:
    image: cloudflare/cloudflared:2026.8.3
    restart: unless-stopped
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    command: tunnel --no-autoupdate run
    environment:
      TUNNEL_TOKEN: ${CLOUDFLARED_TOKEN:?cloudflare tunnel token required}
    depends_on:
      server:
        condition: service_healthy

Compare the capability block to the Caddy service from the certificate post. That one needed cap_add: NET_BIND_SERVICE, because binding port 443 requires it. This one drops every capability and adds nothing back, because it never binds a privileged port. It never binds a port at all. That asymmetry is the tunnel’s security argument in one line of YAML: a process that only makes outbound connections needs less power than one accepting inbound ones.

--no-autoupdate with a pinned image tag is the pairing you want. Letting the binary update itself inside a container whose image you pinned gives you a container that no longer matches its own tag, and the next time you recreate it you roll back to whatever the image holds. Pin the image, disable the self-updater, upgrade by changing the tag.

depends_on with condition: service_healthy stops the tunnel registering with Cloudflare and accepting requests for a service that is not up yet. Without it, a visitor gets a Cloudflare error page with nothing from your stack behind it.

Where the configuration lives

TUNNEL_TOKEN means this is a remotely managed tunnel. The daemon authenticates with the token and is then told what to do over its control connection. The routing rules live in Cloudflare’s dashboard, not in a file on my host.

You can watch it happen in the logs at startup:

1
2
3
INF Updated to new configuration config="{\"ingress\":[{\"hostname\":\"auth.homelabdomain.xyz\",
    \"service\":\"http://server:9000\"}, {\"service\":\"http_status:404\"}],
    \"warp-routing\":{\"enabled\":false}}" version=1

One hostname to one service, and a catch-all returning 404.

The catch-all decides what happens to a request arriving at the tunnel for anything else. Ingress rules are evaluated in order and the last one has to match everything, so http_status:404 is the right answer there. The tempting alternative, a bare service pointing at your application, turns the tunnel into an open door for any hostname anyone can get to resolve at Cloudflare.

The tradeoff of remote management cuts both ways. Against it: the routing lives outside my repository and outside version control, visible on the host only in a log line. My compose file is fully reproducible and my tunnel configuration is not. In its favour: there is no config file to keep in sync, no reload to orchestrate, and changing a route does not require touching the machine. A local config.yml with a credentials file is the alternative if you want the routing in git, and for a single hostname the extra moving parts cost more than they return. For anything with more than a handful of routes I would probably reverse that.

Note the upstream, too: http://server:9000. The Compose service name again, plain HTTP over the Docker bridge, exactly as Caddy reaches it. The tunnel does not go through Caddy at all. auth.homelabdomain.xyz has no site block in my Caddyfile and does not need one, because TLS terminates at Cloudflare’s edge and the last hop is a container talking to a container.

Scoping the access policy

Cloudflare Access sits at the edge, in front of the tunnel, and decides whether a request may proceed at all. A request that fails never reaches the tunnel, so it never reaches my house. Mine uses a one-time PIN: you give it an email address, it sends a code, you enter the code, and Cloudflare issues you a session.

The obvious move with a tool like that is to put it in front of the whole hostname. Nothing reaches authentik until Cloudflare has established that the requester controls one specific mailbox, two independent systems both have to let you through, and the identity provider gets a second lock on its front door.

That is not what I built, because for an identity provider it does not work. The application looks like this:

Cloudflare Access applications list showing one self-hosted application named auth, with destination auth.homelabdomain.xyz/if/admin and a policy named admin only

The destination column carries it, though the editor shows it more clearly, with the hostname and the path as separate fields:

The Destinations panel of the Access application, showing Subdomain auth, Domain homelabdomain.xyz and Path if/admin as three separate fields

Written out:

FieldValue
TypeSelf-hosted
Subdomainauth
Domainhomelabdomain.xyz
Pathif/admin
Policyone rule, allow
Session duration1 month
Identity providersaccept all available, which with none configured means one-time PIN

The destination is not a hostname. It is a hostname and a path. Access evaluates auth.homelabdomain.xyz/if/admin and stops there. The login flow, the OIDC endpoints, the outpost paths and the API all sit outside the policy, reachable from the internet without a PIN.

That looks like an oversight and is the opposite of one. It follows from the constraint that separates an identity provider from every other thing you put behind Access:

An identity provider cannot be blanket-protected, because being reachable is its job.

Work through what a blanket policy would break. A browser being redirected to log in has, by definition, not logged in yet, so it cannot present a session your identity provider issued. Put a challenge in front of the login page and the redirect lands on a Cloudflare prompt where your sign-in form should be. An OIDC client performing a token exchange is a server with no mailbox to receive a PIN at. The forward auth outpost endpoints from the last post exist to be called without a session. Every one of those paths has to answer unauthenticated requests correctly, or the protocol stops functioning.

The protectable surface is therefore the administration interface, the one area no protocol touches. /if/admin holds every high-value action, and no OIDC flow, outpost or token exchange ever reaches it.

That turns the design from a gap into a division of labour. Cloudflare Access guards the console. authentik guards itself, with its own flows, its own policies, and whatever MFA I bind to them, which is what it is built to do and what a proxy in front of it cannot do on its behalf. The two protect different things, and neither backstops the other.

It also raises the stakes on authentik’s own configuration. The login page is on the internet. Its defence is the flow I configured. Misconfigure a stage or leave a weak password policy in place and nothing else is covering for you. The authentication flow is where the care has to go.

The long way round

The session lasts one month. I picked that for a lab where nothing behind the console is critical, and it would be the wrong number somewhere it was.

The Details tab of the Access application showing Session Duration set to 1 month

Because auth.homelabdomain.xyz resolves through public DNS to Cloudflare, a browser on the same switch as the server does not talk to the server when it visits that name. It goes out to my ISP, reaches Cloudflare’s edge, and comes back down the tunnel. The request starts and ends about fifteen feet apart and travels several hundred miles in between.

For the login flow that is mostly invisible, because the round trip is fast and the redirect is a page load. For the admin console behind Access it means an internal administrative session depends on my internet connection and on Cloudflare being up, and a month of session duration keeps that from becoming a daily irritation. A month is also how long a stolen Access session cookie stays useful. Somewhere with real consequences behind the console, a day would be the defensible number.

The forward auth path does not hairpin at all. Following my previous posts in this series, those subrequests go to the per-application hostnames on 192.168.66.6, reached by IP with the name carried only in SNI, which is why those endpoints exist and why none of those names needs a DNS record. Every authorization decision for every service behind the proxy happens without a packet leaving the building. That was the point of setting it up that way. It is the difference between an internet outage costing you a slow login and one costing you every service at once.

What hairpins is browser traffic to auth itself: the login page a redirect lands on, and the admin console. Even that has a direct route, because the first post bound authentik’s ports to the LAN address, not localhost. Reaching the server on its own address works from inside, at the cost of the self-signed certificate warning covered in part two, which is the correct trade for a break-glass path and a bad one for daily use.

A local DNS override pointing the name at the host is a third option, and I have not taken it. It would mean the name resolves differently depending on where you are standing, and for the component everything else depends on I would take one answer that is occasionally slow over two answers that are occasionally different.

Thirty-one hours on three connections

When cloudflared starts it does not open one connection. It opens four, across at least two of Cloudflare’s data centres, so that any single connection or location failing does not take your service down.

That redundancy works. Four connections over a recent two-day window:

1
2
3
4
connIndex=0  registered=1  serve_errors=0
connIndex=1  registered=2  serve_errors=1
connIndex=2  registered=2  serve_errors=225
connIndex=3  registered=1  serve_errors=0

Connection 2 failed at 04:04 on the second, and then failed again, and again, on a backoff, for thirty-one hours. It logged 225 serve errors against a single edge address before registering cleanly at 11:26 the following day. Connections 0, 1 and 3 were healthy throughout, and every request I made was served by one of them.

High availability converts outages into degradation, and degradation is silent by construction. A four-connection tunnel running on three behaves identically to one running on four, right up until the number reaches zero.

1
docker logs cloudflared 2>&1 | grep -oE 'connIndex=[0-9]+' | sort | uniq -c

Wildly uneven counts mean one connection is having a much worse time than the others. Better found during a monthly look around than during a real failure.

The balance

This hostname is now reachable from anywhere without a forwarded port, without anything listening on my public address for it, and with a daemon that holds no Linux capabilities at all. The administration interface sits behind an independent authentication system, and the parts the protocols require to be open are open on purpose.

The cost is a dependency, narrower than it first looks. The tunnel is the only path to that hostname, so Cloudflare being unreachable means the identity provider is unreachable from the internet, and logging in from inside the house gets awkward until you fall back to the host’s own address. Everything already running survives: forward auth stays on the LAN, services behind the proxy keep authenticating people whose sessions are live, and a home internet outage does not take the whole stack down with it. For a homelab that is a comfortable place to land. It is a different calculation if anything you cannot afford to lose depends on new logins succeeding.

None of this puts a second lock on the identity provider’s front door. It puts a lock on the office at the back and leaves the front door defended by authentik, whose job that is.

Get it

The Compose stack, including the cloudflared service above, is in my TechbyJeff repo at Docker/authentik/docker-compose.yml. The tunnel token lives in .env, which is not in that repo and should not be in yours. The ingress rules and the Access policy are not files at all: they live in the Cloudflare dashboard, which is the tradeoff described above.

Sources


This is part four of a series on the identity stack in my homelab. Part one covered deploying authentik itself. Part two covered certificates. Part three covered forward auth. Part five covers Tautulli, which has its own login it does not want to give up. Part six covers OpenMediaVault, which has no delegated authentication mode at all.