A homelab usually starts with a few services on a few machines. One application runs on a small server, another lives on a container host, and each exposes its own private address and port.
At that stage, opening each service directly or keeping a collection of bookmarks is sufficient. The problem appears as the number of services grows. Addresses and ports become part of the user interface, TLS is configured repeatedly, and the same application may acquire one name at home and another when accessed remotely.
Remote access adds another set of decisions. A service may need to be available on the LAN but not from the internet. It may need sign-in without needing public exposure. Another endpoint may intentionally be public without sharing the policy used by every other application.
One-off answers do not scale well. Direct access makes backend addresses part of the access design. Per-service port forwarding, tunnel entries, DNS records, and authentication exceptions are easy to forget, difficult to review together, and difficult to remove cleanly later.
What I needed was not a way to make every service public or to put every service behind the same login. I needed an access layer that could give each service a stable hostname, keep its backend private, and make routing, authentication, and remote publication separate decisions.
A new service should require a small set of declarations rather than a fresh edge design. Local users should use the same hostname as remote users where the service is published. TLS should terminate in one predictable place. Backends should accept application traffic only through the path designed for them.
A reverse proxy provides that access layer. It accepts a request for a hostname, selects the matching backend, and can apply shared controls before forwarding the request. The proxy does not make an application public by itself; it creates one controlled entry point for reaching private services.
I use Traefik for this role. Traefik is a reverse proxy that routes requests by hostname and can attach middleware, such as an authentication check, to individual routes. It is a useful fit when services live on different hosts because the routes and backend targets remain explicit, small, and reviewable.
The rest of the design adds split-horizon DNS for consistent names, Cloudflare Tunnel for deliberate remote ingress, and Authentik where a route needs identity and authorization. The products support the model; the model is the important part.
A reverse-proxy route is not public exposure.
An authentication middleware is not routing.
Treating them as one decision makes every new service harder to reason about.
A route is not public exposure
Each hostname has four independent decisions:
- Internal route: can Traefik reach the backend?
- Authentication: does this route require sign-in and authorization?
- Tunnel publication: may Cloudflare Tunnel forward this hostname to Traefik?
- Public DNS: should the hostname resolve on the public internet at all?
A compact matrix makes the separation clear:
| Service state | Route | Auth | Tunnel | Public DNS |
|---|---|---|---|---|
| LAN-only, protected | Yes | Yes | No | No |
| Public, protected | Yes | Yes | Yes | Yes |
| Public, intentionally unprotected | Yes | No | Yes | Yes |
The final row is deliberate. Some endpoints need to be public without SSO, but that should be a route-level choice, not an accidental consequence of reachability.
The diagram is the operating model: routing, protection, publication, and name resolution can change independently. That removes hidden coupling. External access can be removed without breaking local access, and a protected internal service does not need a public record.
What adding a service actually changes
The workflow comes early because it is where the architecture earns its keep.
For each new service, I use the same order:
- Add a Traefik route for
app.example.testthat points to the private backend. - Attach authentication only when the service needs it.
- Limit the backend so it accepts application traffic from Traefik rather than arbitrary clients.
- If remote access is required, add an explicit Cloudflare Tunnel ingress for that hostname.
- Create public DNS only after the route and tunnel rule exist.
- Test local access, remote access where published, and sign-in behaviour where protected.
This is intentionally uneventful. The same checklist works for a dashboard, a personal application, or a small internal tool. Changes live in versioned configuration rather than being scattered across browser dashboards and memory.
It also makes removal simple. Taking a service off the public internet is a tunnel and DNS change; the internal route can remain in place.
One hostname, two paths
Users should not need one URL at home and another away from home.
With split-horizon DNS, local DNS answers app.example.test with Traefik's private address. Public DNS answers only deliberately published names through Cloudflare's edge and tunnel. Both paths arrive at the same TLS proxy, use the same hostname, and select the same route.
Both paths reach the same route; publication and authentication remain separate choices.
Each component has a narrow role:
- Local DNS gives LAN clients the same names they use remotely.
- Traefik makes the routing and middleware decision.
- Cloudflare Tunnel is the public ingress, not a shortcut around the proxy.
- Authentik supplies identity and authorization where a route requests it.
- Backend services remain private behind the proxy.
This is particularly useful when services run on separate hosts. Traefik's file provider keeps routes and backend targets in small, reviewable files, without granting the proxy Docker socket access merely to discover remote applications.
Authentication is a per-route choice
Authentik is supporting infrastructure here, not the centre of the design. A protected Traefik router uses a named ForwardAuth middleware; an intentionally unprotected router does not.
The middleware illustrates the boundary:
http:
middlewares:
authentik:
forwardAuth:
address: "http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
trustForwardHeader: true
Traefik calls the Outpost over a private path. The Outpost port is not a public application endpoint. Its own route must also remain outside this middleware, otherwise it would try to authenticate itself.
This pattern provides a consistent SSO experience across selected routes without requiring a separate integration for every service. For the broader identity design, see how I use Authentik as a central identity provider.
There is one domain constraint worth designing around. When browser-facing Outpost callbacks and protected applications share a parent-domain cookie, they must sit beneath the same registrable parent domain. outpost.example.test can share that scope with app.example.test; an unrelated parent domain cannot.
Publication must be explicit
Cloudflare Tunnel should point to Traefik, not directly to an application backend. Otherwise the public path bypasses the routing and protection decision the proxy is meant to enforce.
A locally managed tunnel configuration makes publication reviewable. Each public hostname receives an ingress rule, and the final rule rejects everything else:
# tunnel configuration
ingress:
- hostname: app.example.test
service: https://traefik:443
originRequest:
originServerName: app.example.test
- service: http_status:404
The originServerName lets the connector verify Traefik's certificate for the hostname it is routing. TLS verification should remain enabled.
Public DNS comes last. A hostname gets a public record only when it has both a route and a matching tunnel rule. That makes exposure easy to inspect: route, publication rule, DNS record.
How a protected request travels
The LAN and internet paths differ only before Traefik.
A LAN client resolves app.example.test locally and reaches Traefik directly. An internet client resolves a deliberately published hostname through the public edge and Cloudflare Tunnel. From Traefik onwards, the request follows the same protected route.
Traefik asks the private Authentik Outpost whether the request may continue. The Outpost consults Authentik Core when a login or policy decision is needed. Once authorization succeeds, Traefik forwards the request to the backend.
The backend sees the proxy path, not direct internet traffic or the tunnel connector. That makes the firewall boundary easier to understand and maintain. An intentionally unprotected public route follows the same DNS and proxy path, but omits the middleware by design.
Safeguards that keep the pattern useful
This is an architecture pattern rather than a deployment guide, but a few boundaries are worth preserving:
- Keep tunnel and Outpost credentials out of source control, and scope DNS API tokens to the required zone and actions.
- Keep the Traefik dashboard behind TLS and authentication. An insecure dashboard is not an operational shortcut worth taking.
- Use DNS-01 where wildcard certificates are needed, and remember that a wildcard does not cover the zone apex.
- Keep backend access narrow: the application should accept traffic from the proxy path it was designed to trust.
- Test the actual routed ForwardAuth endpoint and both client paths, rather than treating a configuration file as proof of behaviour.
Availability note: Traefik and the identity service are shared dependencies. Keep a documented direct, local break-glass path for critical administration before adding HA.
That recovery path is part of the design, not an admission that the proxy should be bypassed in normal operation. I use the same principle in how I approach reliability and recovery in my homelab. For privileged administrative access, using short-lived SSH certificates with Vault and Authentik is a related pattern with its own recovery considerations.
The trade-offs are visible
| Decision | Benefit | Trade-off |
|---|---|---|
| One proxy route per hostname | Consistent local and remote access | Proxy configuration becomes shared infrastructure |
| Optional ForwardAuth | SSO only where required | Protected routes depend on identity availability |
| Explicit tunnel ingress | Public exposure is reviewable | Remote publication is an extra declaration |
| Split-horizon DNS | One hostname, local path at home | Local DNS becomes part of the access layer |
This is not a high-availability design by itself. It is a proportionate way to remove recurring work while keeping dependencies and recovery needs visible.
A platform for the next service
The value is not uniformity for its own sake. A service can be routed internally without being public. It can be protected without inventing a new identity integration. It can be published without bypassing the proxy.
That is what turns “add a service” from an improvisation into a small set of declarations with clear boundaries.
Route it. Protect it when required. Publish it only when required. Resolve it publicly only when required. Keep a recovery path.