Add anonymized edge infrastructure writeup and Mermaid architecture

This commit is contained in:
interlope committed 2026-11-01 19:32:55 +01:00
1 parent 931b2fc6f3
commit 1cb9c004d8
15 files changed
+1285

No files matched your search

+920
View File
@@ -0,0 +1,920 @@
---
title: "Load Balancing My Homelab"
date: 2026-11-01
slug: load-balancing-my-homelab
author: "spleenftw"
status: published
tags:
- "homelab"
- "haproxy"
- "anubis"
- "cloudflare"
- "headscale"
- "tailscale"
- "ansible"
- "semaphore"
- "docker"
- "monitoring"
- "self-hosting"
description: "How I deployed two HAProxy and Anubis edges with Cloudflare load balancing, Headscale and Semaphore: configuration, failover tests and direct media access."
category: "network"
---
<h2>The homelab has two front doors now</h2>
<p>
In <a href="https://blog.interlope.xyz/my-homelab-setup-2">WIMH #2</a>, I described the VPS entry into the
lab: public traffic lands outside the house, then crosses the overlay to reach my services. The house keeps
its ports closed. That part stays.
</p>
<p>
What changed is the public entrance. The older setup mixed application hosting with Nginx and Anubis on the
same VPS machines. It worked, but moving the entrance onto a dedicated pair of machines gives me somewhere
else to send visitors when one edge fails.
</p>
<p>
So this is the writeup of that migration: two OVH VPS instances, HAProxy on both, an Anubis instance per
service on each, Cloudflare Load Balancing in front, and Semaphore deploying the configuration from Git. The media workload needed its own treatment, because a video client has very little interest in
solving a JavaScript challenge.
</p>
<p>
Grab a coffee. There are health checks inside health checks.
</p>
<blockquote><p>
Node names in this article are aliases. Public example addresses come from documentation ranges; private
addresses, internal DNS names and infrastructure identifiers are omitted or replaced. Screenshot labels have
been anonymized, with graph legends redrawn where needed; plotted values are unchanged. Configuration
fragments are shortened examples, not a dump of my access policy. No credentials, real client addresses, service hostnames or live service-to-host inventory are included. Application labels in screenshots are generic.
</p></blockquote>
<h2>What actually runs where</h2>
<p>
I'll call the public entry machines <code>edge-a</code> and <code>edge-b</code>. They live in different OVH
locations. The existing application VPS machines become <code>origin-a</code> and <code>origin-b</code>, while
the subnet router at home becomes <code>home-router</code>. Nginx Proxy Manager is still the HTTPS gateway to
the local applications.
</p>
<p>
Each edge runs HAProxy and an Anubis instance per configured application. Each Anubis has its own policy and target. The applications
themselves remain on their existing hosts.
</p>
<p>
<img src="../img/architecture.png" alt="Six web services reach either edge through the Cloudflare proxy.
Media clients connect directly to either edge after DNS selection. Both edges use private overlay paths to
VPS origins or the home reverse proxy." />
</p>
<p>The following are illustrative routing patterns, not a catalogue of the live applications or their host assignments.</p>
<table>
<thead><tr><th>Example workload</th><th>Origin path</th><th>Public entrance</th></tr></thead>
<tbody>
<tr><td>Browser application</td><td>Private application host over the overlay</td><td>Cloudflare proxy</td></tr>
<tr><td>Application with native API clients</td><td>Private HTTPS gateway</td><td>Cloudflare proxy with application-specific challenge rules</td></tr>
<tr><td>Media workload</td><td>Private HTTPS gateway</td><td>DNS-only load balancing, direct HTTPS</td></tr>
</tbody></table>
<p>
For a service at home, the request travels through HAProxy, its selected Anubis, the Tailscale overlay, the
home subnet router, the firewall, and NPM before reaching the application. Between the router and NPM, the
firewall permits HTTPS to that gateway. The edges don't get a general invitation into the LAN.
</p>
<p>
Also, a change from that older WIMH post: this setup uses <strong>Headscale</strong> as its control plane. The
clients are still Tailscale clients. The article describing the official Tailscale control plane records an
earlier state of the lab.
</p>
<h3>The complete flow map</h3>
<p>This diagram separates visitor traffic from deployment, control and monitoring. Application groups are illustrative; no real service catalogue, hostnames or private addresses are included. Solid arrows show the main flow within each section, while dashed arrows identify secondary paths, checks or control operations.</p>
<figure>
<a href="../img/infrastructure-overview.png"><img src="../img/infrastructure-overview.png" alt="Detailed anonymized architecture: Cloudflare proxy and DNS-only access, two HAProxy and Anubis edges, private service failover, Tailscale origins, Semaphore deployment, Headscale policy, certificates and Prometheus monitoring" loading="lazy" /></a>
<figcaption>Open the image at full size to follow the paths. Cloudflare checks the public entrance; each HAProxy checks its application paths. The shared origins remain a common dependency.</figcaption>
</figure>
<p>The <a href="../files/infrastructure-mermaid.txt">Mermaid source</a> describes this generic architecture. It is maintained documentation, not a production configuration export.</p>
<h3>Getting the private path ready first</h3>
<p>
The work started behind the public entrance. The edges needed a usable route to each origin before they could
proxy anything. Joining Headscale alone doesn't make a loopback-only application reachable, and a working
local curl doesn't prove that another node has permission to reach it.
</p>
<p>
One web application was a good example. Its existing port was bound to loopback for the old local reverse proxy. A second
binding on its overlay address let the new edges reach it while the old path stayed available during
migration. The other applications also needed their actual listening addresses checked. There was no reason to
open their application ports to the public Internet.
</p>
<p>
For the home services, the subnet router advertised the route to NPM. The edge clients accepted private routes
without using an exit node. Their ordinary Internet egress stayed their own, which also matters later for
Cloudflare API access.
</p>
<p>
The enrollment command looked like this, with the control hostname and node name replaced:
</p>
```bash
sudo tailscale up \
--login-server=https://control.example.net \
--hostname=edge-a \
--advertise-tags=tag:edge \
--accept-routes
```
<p>
The other edge uses its own hostname. Registration and approval happen through Headscale; an enrollment
command is not a substitute for checking the resulting tags, approved routes and connectivity.
</p>
<p>
I kept <code>tag:edge</code> as its own role. The access rules describe the application destinations and ports
the edges need, plus their private peer gateways. Giving these machines a broad management tag would have made
the initial tests easier and the trust boundary much worse.
</p>
<p>
One early timeout between the application VPS machines was consistent with that policy. The machines were
online, but their role didn't grant the origin access intended for the edges. The useful test was from a
correctly tagged edge, not from whichever machine happened to have a shell open.
</p>
<h2>Two different kinds of failover</h2>
<p>
This is the part that took the most explaining during the build. Cloudflare and HAProxy make different
decisions.
</p>
<p>
Cloudflare selects a public edge. Both edges are active, using equal endpoint weights and Random steering
inside one pool. With session affinity, this does not mean every other HTTP request lands on the other VPS. A
browser session can stay on one edge, and a long connection can account for much more traffic than a short
visit.
</p>
<p>
HAProxy then selects a path for the requested service. It prefers the local Anubis. If that local path is
unhealthy, it uses a private gateway on the other edge.
</p>
```text
Normal:
visitor → Cloudflare → edge-a → local Anubis → origin
Local Anubis unavailable:
visitor → Cloudflare → edge-a → private gateway on edge-b
→ edge-b Anubis → origin
edge-a unavailable:
visitor → Cloudflare → edge-b → local Anubis → origin
```
<p>
The second path is per service. Losing one application's Anubis on edge-a does not send the other applications across
the mesh too. Their healthy local paths keep working.
</p>
<p>
The private gateways bind only to the edges' overlay addresses. Headscale controls access, and HAProxy
additionally checks the source edge and the expected Host. Crucially, a private gateway can reach only its own
local Anubis. It cannot send the request back to the peer. No proxy ping-pong.
</p>
<p>
HAProxy inserts a service-specific routing cookie to keep the chosen path consistent through challenges and
subsequent requests. Cloudflare's affinity cookie operates one level earlier, choosing the public edge. They
solve different problems.
</p>
<p>
None of this moves an already-open WebSocket or video connection between machines. A client has to reconnect.
There is also no extra HAProxy policy here to replay an application POST after receiving a response.
</p>
<h3>What the backend actually selects</h3>
<p>
Here is a shortened version of the generated backend on edge-a for an example application. The address tokens stand for
private overlay addresses and must be replaced before using this as configuration:
</p>
```haproxy
backend be_app
cookie EDGE_ROUTE_app insert indirect nocache httponly secure
option httpchk
http-check send meth GET uri /_edge/health ver HTTP/1.1 hdr Host app.example.net
http-check expect status 200
server anubis EDGE_A_MESH_IP:18923 source EDGE_A_MESH_IP cookie edge-a check inter 5s fall 2 rise 2 init-state fully-down
server peer EDGE_B_MESH_IP:18923 source EDGE_A_MESH_IP cookie edge-b backup check inter 5s fall 2 rise 2 init-state fully-down
```
<p>
The server named <code>anubis</code> here points to the local private gateway, which checks the complete local
path. The <code>peer</code> is a backup server. On edge-b, the generated configuration reverses the addresses
but keeps cookie values tied to the actual edge aliases.
</p>
<p>
<code>fall 2</code> and <code>rise 2</code> avoid changing state after one isolated probe result. They don't
promise a ten-second recovery for the whole chain: these gateway checks depend on the separate Anubis and
origin checks, their own intervals, and connection timeouts. The peer cannot be selected until HAProxy
considers it healthy.
</p>
<p>
A session that used the peer can remain there after the local route recovers because its routing cookie still
selects that path. A new session prefers the healthy local server. This is why looking at one browser
immediately after recovery isn't a reliable way to infer where all new traffic goes.
</p>
<h2>A green check has to mean something specific</h2>
<p>
A TCP port can be open while the application behind it is dead. Anubis can also return a perfectly good
challenge page while its target is unavailable. So checking for a generic HTTP 200 would tell a rather
optimistic story.
</p>
<p>
The configuration checks Anubis and the origin separately. A local service gateway is ready only when both are
healthy. With inter-edge redundancy enabled, the public readiness route accepts either a healthy local path or
a healthy peer path.
</p>
<table>
<thead><tr><th>Endpoint</th><th>Question it answers</th></tr></thead>
<tbody>
<tr><td><code>/_edge/entry-health</code></td><td>Can this edge receive HTTPS and respond through HAProxy?</td></tr>
<tr><td><code>/_edge/health</code> on an application Host</td><td>Does this service have a usable local or peer path?</td></tr>
<tr><td>Private gateway health</td><td>Are this edge's own Anubis and origin healthy?</td></tr>
<tr><td><code>/_edge/metrics</code></td><td>What states and traffic does HAProxy report?</td></tr>
</tbody></table>
<p>
The distinction matters because I ended up using one shared Cloudflare pool with two endpoints. Every public
load balancer reuses it. This kept the configuration within the endpoint allocation shown by my subscription,
rather than duplicating a two-endpoint pool for each service.
</p>
<p>
A shared pool also shares its health verdict. Using one application as its monitor would make its failure
influence all the other domains. Requiring every application to pass would let one failed common origin take
both edges out of rotation. Neither describes the health of the entrance very well.
</p>
<p>
The common monitor therefore checks HTTPS ingress only. HAProxy returns <code>edge-ready</code> on a dedicated
Host and path, after checking the network source and monitor token. The monitor expects a valid certificate,
HTTP 200 and that exact body.
</p>
<p>
It deliberately stays green when a shared application is down. The application checks and Prometheus show that
failure separately. <a href="https://developers.cloudflare.com/load-balancing/monitors/">Cloudflare's monitor
documentation</a> describes how those probes determine endpoint health.
</p>
<p>
There is a limit to this choice: an edge can keep answering the ingress probe while one service has lost both
its local route and its route through the peer. If the other edge can still serve that service directly, the
common monitor will not steer just that domain away from the broken edge. Separate service pools could make
that decision. This setup relies on the private per-service fallback for that layer.
</p>
<figure>
<img src="../img/pool.png" alt="Cloudflare pool with two healthy anonymized endpoints, each weighted 1, and Random steering" loading="lazy" />
<figcaption>The shared pool after deployment: both endpoints healthy and enabled, with equal weights. Names and IP addresses have been replaced.</figcaption>
</figure>
<h3>The shared monitor, field by field</h3>
<p>
The monitor is HTTPS on port 443, using GET on <code>/_edge/entry-health</code>. Its configured interval is 60
seconds, timeout five seconds, with two retries. The expected result is HTTP 200 and <code>edge-ready</code>.
The option to skip certificate verification is unchecked.
</p>
<p>
These are monitor settings, not a measurement of how long a visitor waits during failure. Probe scheduling,
health decisions and existing connections all affect the result.
</p>
<figure>
<img src="../img/monitor-settings.png" alt="Cloudflare HTTPS monitor configured with a 60-second interval, five-second timeout, two retries, status 200 and body edge-ready" loading="lazy" />
<figcaption>The actual common monitor settings. Its request headers are configured separately; no token value is included in this screenshot.</figcaption>
</figure>
<h2>Anubis, without breaking the clients I actually use</h2>
<p>
<a href="https://github.com/TecharoHQ/anubis">Anubis</a> adds a browser proof-of-work challenge in front of an
application. It does not replace that application's login. In this deployment, its listeners and metrics stay
on loopback; HAProxy is the public HTTP entrance.
</p>
<p>
The policies follow the application. Browser-oriented services receive a challenge. The notification service's API and stream
routes bypass it so notifications and clients can work. The request application's API does too. The media service challenges its
root and web interface, while native API, media and socket routes continue to the application's
authentication.
</p>
<p>
An Anubis <code>ALLOW</code> rule means "skip this challenge". It does not mean "give this person access to my
libraries".
</p>
<p>
That distinction deserved a real check on the media service. Anonymous requests to the tested private routes, including
users, sessions, libraries, server configuration and API keys, returned 401 on both edges. The public
information route remained public, and the public user-list route returned an empty list. HAProxy blocked
<code>/health</code> and <code>/metrics</code> with 404, while the protected edge readiness route returned 403
without its secret.
</p>
<p>
Those results cover the routes tested, not every plugin or account permission in the media service. They do show why
"block the whole API" would have been the wrong fix: the clients need it.
</p>
<p>
The URL itself also needs attention before path-based restrictions. For the services with strict path rules,
HAProxy normalizes supported encodings and path components, and rejects ambiguous forms before applying
restrictions. Otherwise, protecting <code>/admin</code> while overlooking an equivalent encoded path is an
expensive way to learn about URL parsing.
</p>
<h3>A policy example: the browser route comes first</h3>
<p>
The media policy makes the exception explicit. This is the policy structure rendered by the template,
including the configured challenge difficulty:
</p>
```yaml
bots:
- name: media-browser
path_regex: '(?i)^/(?:web(?:/|$)|$)'
action: CHALLENGE
challenge:
algorithm: fast
difficulty: 5
- name: media-native-routes
path_regex: '^/'
action: ALLOW
```
<p>
The web rule precedes the catch-all allowance, and its match is case-insensitive. The path restrictions in
HAProxy run before selecting the local or remote route, so choosing the peer cannot skip those restrictions.
</p>
<p>
The notification client needs a different list: message delivery, stream connections and client/application routes belong to
its normal API use. The request application needs its API routes for the web UI as well as integrations. Reusing the same
blanket browser policy everywhere would have been simpler YAML and broken clients.
</p>
<p>
The containers also have distinct targets and policy directories. A policy-content hash in their labels causes
a recreation when the policy changes. Mounting a directory rather than a single policy file avoids keeping an
old file inode mounted after an atomic replacement.
</p>
<h2>The media service gets the grey cloud</h2>
<p>
The other web services use proxied Cloudflare load balancers. The media service uses a DNS-only load balancer backed by
the same edge pool. Cloudflare participates in DNS selection and monitoring; the client connects directly to
an edge for HTTPS. The video traffic does not cross Cloudflare's reverse proxy.
</p>
```text
DNS: media hostname → healthy edge address
HTTPS: client → edge-a or edge-b → Anubis → home NPM → the media service
```
<p>
<a href="https://developers.cloudflare.com/reference-architecture/architectures/load-balancing/">Cloudflare
documents both proxied and DNS-only load balancing</a>. In the latter case, failover depends on detection and
DNS caching. An ongoing stream does not migrate, and a client can keep using a cached address for a while.
</p>
<p>
This needed a configuration change before the DNS switch. The proxied services accept public application
traffic from Cloudflare sources, plus explicit administrative exceptions. The media service needs to accept direct
clients on its own vhost. Turning the cloud grey without changing that restriction would have produced a very
well-protected 403 page.
</p>
<p>
I first tested from the administration connection, then from a phone outside that exception. The logs
confirmed direct client traffic on the second edge. Login and playback worked. That external test matters:
testing only through an allowlisted administration address can hide a broken public access rule.
</p>
<figure>
<img src="../img/application-lb.png" alt="Anonymized Cloudflare application load balancer showing a healthy shared pool and two endpoints" loading="lazy" />
<figcaption>An application load balancer after migration, with its identifying labels replaced: one healthy pool with both endpoints. This row shows pool health, not application login behavior.</figcaption>
</figure>
<h2>One catalogue instead of seven copies of everything</h2>
<p>
Getting it running was half the job. Adding the eighth service shouldn't require remembering every place where
the first seven were listed.
</p>
<p>
The exposure configuration now lives in <code>docker/haproxy/vars/services.yml</code>. Each service declares a
permanent ID, domain, origin, policy profile and any special health or access rules. A shortened, anonymized
example:
</p>
```yaml
haproxy_service_catalog:
example_app:
id: 1
domain: app.example.net
origin: {node: origin-a, port: 8080}
profile: browser
admin:
paths: [/admin]
blocked_paths:
- {path: /api, status: 403}
dns: manual
```
<p>
The ID determines the local Anubis, metrics and private gateway ports. It stays stable when entries move
around in the YAML. Removing one service must not silently renumber every other listener.
</p>
<p>
The catalogue generates HAProxy routing, Anubis instances and policies, origin probes, private fallback paths,
and the required managed Headscale rules. A separate plan playbook validates the declarations without
contacting the VPS machines or changing DNS:
</p>
```bash
ansible-playbook -i localhost, docker/haproxy/plan_services.yml
```
<p>
That plan runs in a separate Semaphore template with a local inventory and no deployment secrets. It catches
declaration errors. It does not prove that an origin answers over the network.
</p>
<p>
Application deployment remains separate. The catalogue does not create a database, configure backups or
install a new service. First deploy the application privately and verify it. Then declare how the edges should
expose it.
</p>
<h3>The ports are derived, not counted by hand</h3>
<table>
<thead><tr><th>Listener</th><th>Formula</th><th>Example for ID 1</th><th>Exposure</th></tr></thead>
<tbody>
<tr><td>Anubis HTTP</td><td>8922 + ID</td><td>8923</td><td>Loopback only</td></tr>
<tr><td>Anubis metrics</td><td>9200 + ID</td><td>9201</td><td>Loopback only</td></tr>
<tr><td>Private edge gateway</td><td>18922 + ID</td><td>18923</td><td>Overlay address only</td></tr>
</tbody>
</table>
<p>
The compiler checks IDs, origins, supported profiles and derived listener collisions. An entry doesn't get to
refer to an arbitrary undefined origin. A disabled service leaves the active routing and Compose definition,
and managed permissions can be removed on the next deployment. Its data and public DNS are not automatically
deleted.
</p>
<p>
The plan writes a service summary and a Headscale ACL fragment on the runner. That gives me something concrete
to review before a deployment. It also keeps the maintenance path short: edit the catalogue, inspect the plan,
deploy to the edges, check the application, then publish the domain separately.
</p>
<h3>Adding an eighth service</h3>
<p>
For a new application behind home NPM, I first create its Proxy Host and confirm the internal application
works. NPM needs a valid certificate for the application hostname, and WebSocket support when the service uses
it. From the edge, testing with private resolution keeps TLS verification meaningful:
</p>
```bash
curl --noproxy '*' --connect-timeout 5 --max-time 15 \
--resolve app.example.net:443:NPM_PRIVATE_IP \
-I https://app.example.net/
```
<p>
Then comes a catalogue entry. This example is illustrative; it is not an eighth deployed service:
</p>
```yaml
example_app:
id: 8
domain: app.example.net
origin:
node: npm
port: 443
scheme: https
profile: browser
health:
path: /
status: '200-399'
dns: manual
```
<p>
For HTTPS origins, Anubis uses the application hostname while the deployment pins that name to the private
address. The HAProxy origin probe also sends the right SNI and verifies the certificate against that name.
Connecting privately is no excuse for adding <code>-k</code> and hoping for the best.
</p>
<p>
A service with clients that cannot solve a browser challenge may need <code>profile: native</code> and an
explicit health route returning 200. That choice leaves authentication with the application. A new policy
behavior outside the existing profiles is a code change, not something the catalogue can guess.
</p>
<h2>What Semaphore actually does</h2>
<p>
Semaphore runs <code>docker/haproxy/deploy_haproxy.yml</code> from the repository, with the inventory, SSH
credentials, privilege-escalation settings and secret variable group. The pair deploys serially, one edge at a
time.
</p>
<p>
The playbook checks the target and mesh membership, verifies routes and origin responses, obtains the
certificate, pulls the selected images, and validates the candidate HAProxy configuration with the downloaded
image before installing it. After deployment it checks TLS, challenges, aliases, restrictions and readiness.
With peer readiness required, a healthy fallback cannot hide a broken local path: both receive their own
checks.
</p>
<p>
The images intentionally use <code>haproxy:lts</code> and Anubis <code>latest</code>. A deployment can
therefore bring an image update as well as a configuration change. That's part of how this lab is operated,
not a claim that a moving tag is reproducible.
</p>
<p>
Four secrets have separate jobs:
</p>
<ul>
<li>The Cloudflare DNS API token is used for DNS-01 certificate validation and the dedicated edge DNS records.</li>
<li>The Anubis signing key is shared by the instances on both edges.</li>
<li>The monitor token authorizes external health probes.</li>
<li>The metrics token authorizes Prometheus collection.</li>
</ul>
<p>
They are stored as secret extra variables in Semaphore. The playbook also supports environment fallbacks, but
there is no reason to define competing copies in both places.
</p>
<p>
Headscale management runs through delegated Ansible tasks on the control host. It reads the active policy,
updates the rules owned by this deployment, validates a candidate with Headscale, backs up the previous file
and reloads the policy. An adjacent tracking file records which rules it owns so obsolete permissions can be
removed. The rest of the policy stays intact.
</p>
<p>
Cloudflare application load balancers remain configured separately. The playbook manages dedicated DNS-only
edge names, and has an optional single-primary DNS publication mode. That mode points at one edge; it is not
the two-edge load balancer. The migrated services keep manual application publication in the catalogue.
</p>
<h3>Bootstrap, then require the peer</h3>
<p>
The first edge cannot pass a peer check against a machine that hasn't been deployed yet. Bootstrap therefore
happens with peer readiness not required. Once both stacks run, I enable the blocking peer validation and
deploy again:
</p>
```yaml
haproxy_edge_redundancy_enabled: true
haproxy_edge_require_peer_ready: true
```
<p>
That second setting stays enabled for normal operation. It makes an incomplete peer configuration fail the
deployment instead of quietly shipping a broken fallback. A maintenance window on the peer can make this
validation fail even while the running local path continues serving users; deployment success and current
local availability are different questions.
</p>
<p>
The deployment template targets the edge inventory group. A typical ordinary extra-variable selection, with
aliases substituted, is:
</p>
```json
{"target_hosts": "vps_haproxy"}
```
<p>
The SSH account must work on each edge and on the host receiving delegated Headscale tasks. Collections also
have to be available to the runner. In the successful deployment, Galaxy installation is skipped because the
execution environment already has its required collections. Skipping that step does not install missing
modules.
</p>
<h3>A successful run, with the retries left in</h3>
<p>
The migration deployment completed on both edges with no unreachable hosts and no failed tasks. I retain the full job output privately; this article describes the checks without publishing its application inventory.
</p>
<p>
Before completing the run, both edges retried the per-domain HTTPS check once. The eventual checks passed. A
retry message in the middle of this log is therefore not a failed final deployment, but it is still useful
information about startup convergence.
</p>
<p>
The run checked every configured service's public TLS route on the selected node, its browser-policy behavior
where applicable, the four blog-alias redirects, and service readiness. It then checked the local paths
separately and required every configured peer path. That last distinction prevents a working peer from masking a
broken local installation during rollout.
</p>
<p>
The access tests included the blog API and administration paths, an encoded-path case, the media service health and
metrics restrictions, and the metrics route. They verify the specific expected responses in the playbook; they
aren't a full application penetration test.
</p>
<p>
The Headscale candidate was validated during this run. Reloading was skipped because the active policy did not
change. Temporary candidate files also count as changes in the job. A change counter is not a count of application settings altered.
</p>
<p>
The application publication task was skipped, and the final message explicitly left the Cloudflare pools and
affinity unchanged. Semaphore also reported successful delivery of its deployment notification. That is useful
deployment feedback, alongside the separate functional notification tests.
</p>
<h2>The bits that complained during deployment</h2>
<p>
The first edge did not materialize from one flawless green Semaphore run. There were runner dependency and
proxy issues, then SSH user and sudo settings to align with the working templates. Having a valid SSH key is
only useful if the template chooses the account that can use it.
</p>
<p>
Certificate issuance then failed because the Cloudflare token restricted the allowed source addresses. Certbot
runs on the edge, so Cloudflare saw the edge's public egress, not the Semaphore runner. The token needed to
authorize each issuing edge for renewals too.
</p>
<p>
Certificates are obtained locally through DNS-01. The renewal hook assembles the certificate and private key,
checks that they match, validates HAProxy and signals its master to reload. If validation or signalling fails,
it restores the previous PEM on disk. That is a local safeguard, not a full transactional rollback of a
deployment.
</p>
<p>
Later, both Cloudflare endpoints showed a TLS name mismatch even though the local HTTPS probe worked. The fix
was in the monitor headers:
</p>
```text
Host: edge-health.example.net
X-Edge-Health-Token: <monitor secret>
```
<p>
Those are two headers. The technical hostname is the value of <code>Host</code>; it is not a header name. The
monitor uses that Host for TLS SNI. Endpoint Host overrides stay empty so they don't replace the intended
name. Certificate verification stayed enabled. <a
href="https://developers.cloudflare.com/load-balancing/troubleshooting/common-error-codes/">Cloudflare's error
reference</a> covers this mismatch.
</p>
<h2>Trust the connection before trusting its headers</h2>
<p>
For proxied requests, HAProxy accepts the visitor address in <code>CF-Connecting-IP</code> only when the
actual connection comes from a configured Cloudflare range. Otherwise, the connection source is the client
address. It rebuilds forwarding headers before passing traffic downstream.
</p>
<p>
Without that boundary, anyone able to reach the listener could claim to be the administration address by
supplying a header. Source filters for metrics and monitors use the real network peer, not the reconstructed
visitor address.
</p>
<p>
The Cloudflare ranges are an explicit snapshot in configuration. They need review when Cloudflare changes
them. Anubis stays on loopback, private gateways stay on the overlay, and the unauthenticated local Prometheus
exporter stays on loopback too.
</p>
<p>
HAProxy logs connection and backend information without logging request URLs. The notification service and the media service can carry
credentials in query strings; those URLs do not belong in a log excerpt pasted into a blog post.
</p>
<h2>Yes, there are graphs</h2>
<p>
The existing Prometheus stack collects HAProxy metrics over public HTTPS using dedicated DNS-only names for
each edge. The monitoring host doesn't need to join the mesh. The metrics route requires both an authorized
source address and its own Bearer token.
</p>
<p>
Grafana can then show traffic, sessions, backend errors and health by edge and service. Local Anubis and
origin states remain separate, with the peer path alongside them. A green peer path can keep the application
reachable while a local component needs repair.
</p>
<p>
Traffic share needs some restraint when interpreting it. HAProxy sees requests that reach the edges, not
requests already satisfied by Cloudflare's cache. A video stream and a blog visit aren't comparable units of
work. Equal endpoint weights won't make those graphs perfectly symmetrical.
</p>
<p>
Likewise, HAProxy metrics don't contain Cloudflare's actual pool verdict. That belongs to Cloudflare's monitor
and event view. And a dashboard import doesn't install Prometheus alert rules or configure notification
delivery. The reference rules still need to be loaded and connected to the existing alerting setup.
</p>
<h3>The scrape configuration</h3>
<p>
Each target has a dedicated DNS-only name. If I used an application load-balancer name here, I could scrape
whichever edge got selected twice and believe I was observing both. The labels and targets below are
anonymized, and the proxy address stands in for the monitoring stack's existing outbound proxy:
</p>
```yaml
scrape_configs:
- job_name: haproxy-edges
scrape_interval: 15s
scrape_timeout: 10s
scheme: https
proxy_url: http://monitor-proxy.example.net:3128
metrics_path: /_edge/metrics
authorization:
type: Bearer
credentials_file: /etc/prometheus/secrets/edge-metrics-token
tls_config:
insecure_skip_verify: false
static_configs:
- targets: ['edge-a.example.net:443']
labels:
edge: a
- targets: ['edge-b.example.net:443']
labels:
edge: b
```
<p>
The secret file must be mounted and readable inside Prometheus. The source restriction on HAProxy needs to
match the actual public egress of that scrape path. A correct token coming from another source is still
refused.
</p>
<p>
The basic reachability query is <code>up{job="haproxy-edges"}</code>. A value of one means the scrape
succeeded. It does not mean every application is up, nor that Cloudflare agrees with HAProxy about an
endpoint's state.
</p>
<figure>
<img src="../img/traffic.png" alt="Grafana scrape availability, traffic share, per-service response rates and response bytes for the two anonymized edges" loading="lazy" />
<figcaption>A real observation window after migration. Both scrape series are at one; traffic reaching Anubis is uneven across the two edges. Legend labels have been anonymized.</figcaption>
</figure>
<p>
The traffic screenshot shows a larger slice for one edge and a short burst of media response bytes. It is a
snapshot of requests and bytes reaching this layer, not a benchmark or a unique-user count. Equal configured
weights do not make an uneven short window evidence of a routing fault.
</p>
<figure>
<img src="../img/health.png" alt="Grafana service-path and origin health, active backend sessions and backend 5xx responses" loading="lazy" />
<figcaption>The sampled health window contains brief drops and a few backend errors, followed by healthy values. The screenshot alone does not identify their cause.</figcaption>
</figure>
<p>
There is a naming trap in this dashboard version: the panel titled "Anubis health" displays <code>be_*</code>
series. With redundancy enabled, those backends represent service paths through the private gateways. I read
them alongside the <code>probe_*</code> origin series, rather than treating that title as proof of an isolated
Anubis process state.
</p>
<p>
The short health drops and error peaks are visible around the same part of the window. To assign a cause, I
would need the corresponding logs and a time-aligned deployment or incident event. These captures don't
establish an end-user outage duration.
</p>
<figure>
<img src="../img/latency.png" alt="Grafana rolling backend response times and health-check failure rates" loading="lazy" />
<figcaption>Rolling response time and check failures. The health-check legend containing infrastructure labels has been removed; the plotted samples are unchanged.</figcaption>
</figure>
<p>
One rolling response-time series rises to roughly 850 ms and then stays flat in this window. That's a reason
to inspect the underlying samples and request volume, not enough information to announce a regression or a
percentile. The panel is a rolling average; it doesn't show a p95 latency, and a quiet service can retain a
value without generating much new traffic.
</p>
<h2>The migration, with things deliberately stopped</h2>
<p>
I deployed and tested the edges before changing the public application names. Local resolution overrides let
the browser use the real hostname and certificate while contacting the new machine. The public DNS still
served the older entrance during those tests.
</p>
<p>
The checks went beyond seeing a challenge page: application logins, live updates, notifications and the media service
playback all needed their own tests. Both edges also passed the deployment's local and peer
readiness checks with peer validation required.
</p>
<p>
For the private fallback test, one application's Anubis on edge-a was stopped. Requests entering edge-a used the
peer path, and a WebSocket opened through edge-b. The application origin stayed running. That tested the
fallback layer without deliberately taking the shared application down.
</p>
<p>
The public migration started with one status service. After its load balancer was active, HAProxy on edge-a
was stopped. Cloudflare detected the failed entrance, the other edge served the status application, and edge-a
was restored. This was an actual stop-and-recover test; it wasn't just two green endpoints sitting next to
each other.
</p>
<p>
The remaining proxied services followed, with logins and notifications checked after migration. The root
domain and <code>www</code> were pointed at the blog and their redirects checked. The media service moved separately to
DNS-only load balancing, followed by direct access and playback tests from the phone.
</p>
<p>
Finally, the old application vhosts were disabled and the redundant old Anubis containers stopped. The
application containers stayed running: the new edges still use them. Nginx and Certbot also stayed where other
active services needed them, including the Headscale entrance. Disabling those old vhosts means rollback now
requires restoring them as well as changing the public routing.
</p>
<h3>The commands behind the checks</h3>
<p>
A direct test can target the new edge without changing public DNS. This example address is a documentation
address, not one of my VPS addresses:
</p>
```bash
curl --noproxy '*' --silent --show-error \
--connect-timeout 5 --max-time 15 \
--resolve app.example.net:443:192.0.2.10 \
--output /dev/null \
--write-out 'HTTP %{http_code}, TLS %{ssl_verify_result}\n' \
https://app.example.net/
```
<p>
The hostname stays in the URL so Host and SNI remain correct. A TLS verification result of zero and a valid
response establish the connection, but HTTP 200 may still be an Anubis challenge. The next step is opening the
application and using it.
</p>
<p>
On the edge itself, the common monitor can be checked over loopback with certificate verification intact:
</p>
```bash
curl --noproxy '*' --silent --show-error --max-time 10 \
--resolve edge-health.example.net:443:127.0.0.1 \
https://edge-health.example.net/_edge/entry-health
```
<p>
The expected body is <code>edge-ready</code>. Loopback is exempt from the external monitor token requirement.
That local result cannot prove Cloudflare's external probe source, headers or firewall access are correct.
</p>
<p>
The two controlled stop tests exercise different layers:
</p>
<table>
<thead><tr><th>Stopped component</th><th>Expected routing</th><th>Evidence used</th></tr></thead>
<tbody>
<tr><td>One service's local Anubis</td><td>That service enters the same edge, then uses the peer</td><td>Peer backend selection and a working application/WebSocket</td></tr>
<tr><td>One edge's HAProxy</td><td>Cloudflare marks that entrance unavailable and selects the other</td><td>Cloudflare state, surviving edge logs and browser access</td></tr>
</tbody>
</table>
<p>
For the first case, the relevant container name has the shape <code>haproxy-prod-anubis-example_app</code>. For
the second, it is <code>haproxy-prod</code>. The restoration is starting that same container again and waiting
for health checks to settle. These were controlled tests during migration, not commands to run casually
against both entrances at once.
</p>
<p>
I also kept the public readiness and metrics routes in the negative tests: an unauthenticated request from the
workstation received 403. A monitoring route that returns the expected data only to its authorized caller is
behaving differently from one that happens to answer everyone.
</p>
<h3>Cleaning up without deleting the origins</h3>
<p>
The old application CNAME records were retained while the same-name Cloudflare load balancers took over public
routing. That made the DNS side of rollback simpler during migration. It did not preserve the old application
entrance after its Nginx vhost was disabled.
</p>
<p>
The cleanup therefore happened in stages: disable migrated application vhosts, validate and reload Nginx,
check the retained services, then stop the old Anubis containers. The origin applications, the control-plane
entrance and other still-used vhosts remained in place. Certificates weren't removed just because one
application name had migrated.
</p>
<p>
This is also why the old Nginx deployment playbook isn't part of the new edge procedure. Rerunning it during
cleanup could recreate the entrance I had just retired. The two playbooks belong to different architectures.
</p>
<h2>What two edges do not fix</h2>
<p>
Both entrances use the same application origins. If a single application instance fails, neither edge can
manufacture a replacement application. If the home connection, home gateway or NPM fails, the home-hosted services can lose
both paths at once.
</p>
<p>
The pair also remains with one VPS provider, and Cloudflare is still a dependency for the proxied services.
Different locations reduce some shared failure risks; they don't remove every one.
</p>
<p>
What is deployed now is a redundant public entrance, with per-service private fallback and a catalogue that
makes the next addition less tedious. Application availability still depends on each application's own
storage, backups and redundancy.
</p>
<p>
Seven services migrated, two entrances answering, and a stopped edge no longer being the end of the story.
That's a useful place to leave this round of homelab-ing.
</p>
<p>
Thanks for reading me,
</p>
<p>
spleenftw
</p>
@@ -0,0 +1,114 @@
# Infrastructure overview
Generic documentation: no real service inventory, node names or private addresses.
```mermaid
flowchart TB
subgraph PUBLIC["Public access"]
direction LR
WEB["Browser and API clients"]
MEDIA["Direct media clients"]
DNS["Cloudflare DNS-only LB<br/>returns healthy edge addresses"]
CF["Cloudflare proxied LB<br/>shared pool, equal weights<br/>session affinity"]
PROBE["Cloudflare HTTPS monitor<br/>ingress liveness only<br/>source allowlist + monitor token"]
WEB --> CF
MEDIA -. "DNS lookup" .-> DNS
end
subgraph EDGES["Dedicated entry layer"]
direction LR
subgraph EDGEA["edge-a"]
direction TB
HA["HAProxy HTTPS ingress<br/>validate Host and source<br/>rebuild trusted client headers<br/>normalize paths and apply access rules"]
SELECTA["Service backend<br/>prefer local path<br/>peer as backup + routing cookie"]
GWA["Private service gateway<br/>overlay listener, trusted edges only<br/>local Anubis AND origin must be healthy"]
ANA["Per-service Anubis on loopback<br/>browser challenge or client-compatible policy<br/>application authentication still required"]
HA --> SELECTA
SELECTA -->|"primary"| GWA
GWA --> ANA
end
subgraph EDGEB["edge-b"]
direction TB
HB["HAProxy HTTPS ingress<br/>validate Host and source<br/>rebuild trusted client headers<br/>normalize paths and apply access rules"]
SELECTB["Service backend<br/>prefer local path<br/>peer as backup + routing cookie"]
GWB["Private service gateway<br/>overlay listener, trusted edges only<br/>local Anubis AND origin must be healthy"]
ANB["Per-service Anubis on loopback<br/>browser challenge or client-compatible policy<br/>application authentication still required"]
HB --> SELECTB
SELECTB -->|"primary"| GWB
GWB --> ANB
end
SELECTA -. "per-service backup over encrypted overlay" .-> GWB
SELECTB -. "per-service backup over encrypted overlay" .-> GWA
end
CF -->|"HTTPS to a healthy endpoint"| HA
CF -->|"HTTPS to a healthy endpoint"| HB
MEDIA -->|"direct HTTPS after DNS selection"| HA
MEDIA -->|"direct HTTPS after DNS selection"| HB
PROBE -. "GET /_edge/entry-health: edge-ready" .-> HA
PROBE -. "GET /_edge/entry-health: edge-ready" .-> HB
MESH["Encrypted Tailscale data plane<br/>private routes and least-privilege access<br/>Headscale is the control plane, not an HTTP transit hop"]
ANA --> MESH
ANB --> MESH
subgraph ORIGINS["Shared private origins"]
direction LR
VPS["Private application hosts<br/>applications bound to private listeners"]
ROUTER["Home subnet router<br/>approved route to HTTPS gateway"]
FW["Home firewall<br/>allow HTTPS to gateway"]
NPM["Nginx Proxy Manager<br/>private HTTPS with valid origin certificate"]
APPS["Home applications<br/>native accounts, sessions and tokens"]
ROUTER --> FW --> NPM --> APPS
end
MESH --> VPS
MESH --> ROUTER
subgraph OPS["Deployment and control"]
direction LR
GIT["Git repository<br/>service catalogue + generic templates<br/>no credentials in source"]
PLAN["Semaphore plan task<br/>local inventory, no deployment secrets<br/>validate catalogue + derived rules"]
DEPLOY["Semaphore deployment task<br/>SSH + sudo, serial rollout<br/>validate candidate with selected HAProxy image<br/>check local AND peer paths"]
SECRETS["Separate secret values<br/>DNS API / Anubis signing<br/>monitor / metrics"]
HS["Headscale control host<br/>validate and back up policy candidate<br/>preserve unrelated rules, then reload"]
CERT["Certbot on each edge<br/>DNS-01 certificate issuance and renewal<br/>validate certificate/key and reload HAProxy"]
CFAPI["Cloudflare DNS API<br/>certificate challenge + dedicated edge records<br/>application LB publication managed separately"]
GIT --> PLAN
GIT --> DEPLOY
SECRETS -.-> DEPLOY
DEPLOY -. "delegated policy management" .-> HS
DEPLOY -. "configure local renewal" .-> CERT
CERT -. "DNS-01" .-> CFAPI
end
DEPLOY -. "deploy and verify" .-> HA
DEPLOY -. "deploy and verify" .-> HB
HS -. "control and access policy" .-> MESH
subgraph MONITORING["Monitoring outside the mesh"]
direction LR
PROM["Prometheus<br/>one DNS-only target per edge<br/>verified TLS + authorized source + metrics token"]
GRAF["Grafana<br/>traffic, sessions, service paths<br/>origin probes and backend errors"]
ALERT["Optional alert rules and delivery<br/>must be configured separately"]
PROM --> GRAF
PROM -.-> ALERT
end
PROM -. "HTTPS /_edge/metrics" .-> HA
PROM -. "HTTPS /_edge/metrics" .-> HB
LIMITS["Failure boundaries<br/>Cloudflare bypasses an unavailable public edge<br/>HAProxy bypasses a failed per-service path when its peer is healthy<br/>a failed shared origin is still unavailable<br/>open video streams and WebSockets must reconnect"]
ORIGINS -.-> LIMITS
classDef public fill:#233d58,stroke:#7ab5dc,color:#f2f6ff
classDef edge fill:#284c44,stroke:#7bc8a7,color:#f2f6ff
classDef origin fill:#514132,stroke:#d6b48c,color:#f2f6ff
classDef ops fill:#43395c,stroke:#b4a0de,color:#f2f6ff
classDef monitor fill:#34495b,stroke:#9bbdd4,color:#f2f6ff
classDef note fill:#4c303a,stroke:#dd9ba7,color:#fff5f6
class WEB,MEDIA,DNS,CF,PROBE public
class HA,HB,SELECTA,SELECTB,GWA,GWB,ANA,ANB,MESH edge
class VPS,ROUTER,FW,NPM,APPS origin
class GIT,PLAN,DEPLOY,SECRETS,HS,CERT,CFAPI ops
class PROM,GRAF,ALERT monitor
class LIMITS note
```
@@ -0,0 +1,108 @@
flowchart TB
subgraph PUBLIC["Public access"]
direction LR
WEB["Browser and API clients"]
MEDIA["Direct media clients"]
DNS["Cloudflare DNS-only LB<br/>returns healthy edge addresses"]
CF["Cloudflare proxied LB<br/>shared pool, equal weights<br/>session affinity"]
PROBE["Cloudflare HTTPS monitor<br/>ingress liveness only<br/>source allowlist + monitor token"]
WEB --> CF
MEDIA -. "DNS lookup" .-> DNS
end
subgraph EDGES["Dedicated entry layer"]
direction LR
subgraph EDGEA["edge-a"]
direction TB
HA["HAProxy HTTPS ingress<br/>validate Host and source<br/>rebuild trusted client headers<br/>normalize paths and apply access rules"]
SELECTA["Service backend<br/>prefer local path<br/>peer as backup + routing cookie"]
GWA["Private service gateway<br/>overlay listener, trusted edges only<br/>local Anubis AND origin must be healthy"]
ANA["Per-service Anubis on loopback<br/>browser challenge or client-compatible policy<br/>application authentication still required"]
HA --> SELECTA
SELECTA -->|"primary"| GWA
GWA --> ANA
end
subgraph EDGEB["edge-b"]
direction TB
HB["HAProxy HTTPS ingress<br/>validate Host and source<br/>rebuild trusted client headers<br/>normalize paths and apply access rules"]
SELECTB["Service backend<br/>prefer local path<br/>peer as backup + routing cookie"]
GWB["Private service gateway<br/>overlay listener, trusted edges only<br/>local Anubis AND origin must be healthy"]
ANB["Per-service Anubis on loopback<br/>browser challenge or client-compatible policy<br/>application authentication still required"]
HB --> SELECTB
SELECTB -->|"primary"| GWB
GWB --> ANB
end
SELECTA -. "per-service backup over encrypted overlay" .-> GWB
SELECTB -. "per-service backup over encrypted overlay" .-> GWA
end
CF -->|"HTTPS to a healthy endpoint"| HA
CF -->|"HTTPS to a healthy endpoint"| HB
MEDIA -->|"direct HTTPS after DNS selection"| HA
MEDIA -->|"direct HTTPS after DNS selection"| HB
PROBE -. "GET /_edge/entry-health: edge-ready" .-> HA
PROBE -. "GET /_edge/entry-health: edge-ready" .-> HB
MESH["Encrypted Tailscale data plane<br/>private routes and least-privilege access<br/>Headscale is the control plane, not an HTTP transit hop"]
ANA --> MESH
ANB --> MESH
subgraph ORIGINS["Shared private origins"]
direction LR
VPS["Private application hosts<br/>applications bound to private listeners"]
ROUTER["Home subnet router<br/>approved route to HTTPS gateway"]
FW["Home firewall<br/>allow HTTPS to gateway"]
NPM["Nginx Proxy Manager<br/>private HTTPS with valid origin certificate"]
APPS["Home applications<br/>native accounts, sessions and tokens"]
ROUTER --> FW --> NPM --> APPS
end
MESH --> VPS
MESH --> ROUTER
subgraph OPS["Deployment and control"]
direction LR
GIT["Git repository<br/>service catalogue + generic templates<br/>no credentials in source"]
PLAN["Semaphore plan task<br/>local inventory, no deployment secrets<br/>validate catalogue + derived rules"]
DEPLOY["Semaphore deployment task<br/>SSH + sudo, serial rollout<br/>validate candidate with selected HAProxy image<br/>check local AND peer paths"]
SECRETS["Separate secret values<br/>DNS API / Anubis signing<br/>monitor / metrics"]
HS["Headscale control host<br/>validate and back up policy candidate<br/>preserve unrelated rules, then reload"]
CERT["Certbot on each edge<br/>DNS-01 certificate issuance and renewal<br/>validate certificate/key and reload HAProxy"]
CFAPI["Cloudflare DNS API<br/>certificate challenge + dedicated edge records<br/>application LB publication managed separately"]
GIT --> PLAN
GIT --> DEPLOY
SECRETS -.-> DEPLOY
DEPLOY -. "delegated policy management" .-> HS
DEPLOY -. "configure local renewal" .-> CERT
CERT -. "DNS-01" .-> CFAPI
end
DEPLOY -. "deploy and verify" .-> HA
DEPLOY -. "deploy and verify" .-> HB
HS -. "control and access policy" .-> MESH
subgraph MONITORING["Monitoring outside the mesh"]
direction LR
PROM["Prometheus<br/>one DNS-only target per edge<br/>verified TLS + authorized source + metrics token"]
GRAF["Grafana<br/>traffic, sessions, service paths<br/>origin probes and backend errors"]
ALERT["Optional alert rules and delivery<br/>must be configured separately"]
PROM --> GRAF
PROM -.-> ALERT
end
PROM -. "HTTPS /_edge/metrics" .-> HA
PROM -. "HTTPS /_edge/metrics" .-> HB
LIMITS["Failure boundaries<br/>Cloudflare bypasses an unavailable public edge<br/>HAProxy bypasses a failed per-service path when its peer is healthy<br/>a failed shared origin is still unavailable<br/>open video streams and WebSockets must reconnect"]
ORIGINS -.-> LIMITS
classDef public fill:#233d58,stroke:#7ab5dc,color:#f2f6ff
classDef edge fill:#284c44,stroke:#7bc8a7,color:#f2f6ff
classDef origin fill:#514132,stroke:#d6b48c,color:#f2f6ff
classDef ops fill:#43395c,stroke:#b4a0de,color:#f2f6ff
classDef monitor fill:#34495b,stroke:#9bbdd4,color:#f2f6ff
classDef note fill:#4c303a,stroke:#dd9ba7,color:#fff5f6
class WEB,MEDIA,DNS,CF,PROBE public
class HA,HB,SELECTA,SELECTB,GWA,GWB,ANA,ANB,MESH edge
class VPS,ROUTER,FW,NPM,APPS origin
class GIT,PLAN,DEPLOY,SECRETS,HS,CERT,CFAPI ops
class PROM,GRAF,ALERT monitor
class LIMITS note
@@ -0,0 +1,108 @@
flowchart TB
subgraph PUBLIC["Public access"]
direction LR
WEB["Browser and API clients"]
MEDIA["Direct media clients"]
DNS["Cloudflare DNS-only LB<br/>returns healthy edge addresses"]
CF["Cloudflare proxied LB<br/>shared pool, equal weights<br/>session affinity"]
PROBE["Cloudflare HTTPS monitor<br/>ingress liveness only<br/>source allowlist + monitor token"]
WEB --> CF
MEDIA -. "DNS lookup" .-> DNS
end
subgraph EDGES["Dedicated entry layer"]
direction LR
subgraph EDGEA["edge-a"]
direction TB
HA["HAProxy HTTPS ingress<br/>validate Host and source<br/>rebuild trusted client headers<br/>normalize paths and apply access rules"]
SELECTA["Service backend<br/>prefer local path<br/>peer as backup + routing cookie"]
GWA["Private service gateway<br/>overlay listener, trusted edges only<br/>local Anubis AND origin must be healthy"]
ANA["Per-service Anubis on loopback<br/>browser challenge or client-compatible policy<br/>application authentication still required"]
HA --> SELECTA
SELECTA -->|"primary"| GWA
GWA --> ANA
end
subgraph EDGEB["edge-b"]
direction TB
HB["HAProxy HTTPS ingress<br/>validate Host and source<br/>rebuild trusted client headers<br/>normalize paths and apply access rules"]
SELECTB["Service backend<br/>prefer local path<br/>peer as backup + routing cookie"]
GWB["Private service gateway<br/>overlay listener, trusted edges only<br/>local Anubis AND origin must be healthy"]
ANB["Per-service Anubis on loopback<br/>browser challenge or client-compatible policy<br/>application authentication still required"]
HB --> SELECTB
SELECTB -->|"primary"| GWB
GWB --> ANB
end
SELECTA -. "per-service backup over encrypted overlay" .-> GWB
SELECTB -. "per-service backup over encrypted overlay" .-> GWA
end
CF -->|"HTTPS to a healthy endpoint"| HA
CF -->|"HTTPS to a healthy endpoint"| HB
MEDIA -->|"direct HTTPS after DNS selection"| HA
MEDIA -->|"direct HTTPS after DNS selection"| HB
PROBE -. "GET /_edge/entry-health: edge-ready" .-> HA
PROBE -. "GET /_edge/entry-health: edge-ready" .-> HB
MESH["Encrypted Tailscale data plane<br/>private routes and least-privilege access<br/>Headscale is the control plane, not an HTTP transit hop"]
ANA --> MESH
ANB --> MESH
subgraph ORIGINS["Shared private origins"]
direction LR
VPS["Private application hosts<br/>applications bound to private listeners"]
ROUTER["Home subnet router<br/>approved route to HTTPS gateway"]
FW["Home firewall<br/>allow HTTPS to gateway"]
NPM["Nginx Proxy Manager<br/>private HTTPS with valid origin certificate"]
APPS["Home applications<br/>native accounts, sessions and tokens"]
ROUTER --> FW --> NPM --> APPS
end
MESH --> VPS
MESH --> ROUTER
subgraph OPS["Deployment and control"]
direction LR
GIT["Git repository<br/>service catalogue + generic templates<br/>no credentials in source"]
PLAN["Semaphore plan task<br/>local inventory, no deployment secrets<br/>validate catalogue + derived rules"]
DEPLOY["Semaphore deployment task<br/>SSH + sudo, serial rollout<br/>validate candidate with selected HAProxy image<br/>check local AND peer paths"]
SECRETS["Separate secret values<br/>DNS API / Anubis signing<br/>monitor / metrics"]
HS["Headscale control host<br/>validate and back up policy candidate<br/>preserve unrelated rules, then reload"]
CERT["Certbot on each edge<br/>DNS-01 certificate issuance and renewal<br/>validate certificate/key and reload HAProxy"]
CFAPI["Cloudflare DNS API<br/>certificate challenge + dedicated edge records<br/>application LB publication managed separately"]
GIT --> PLAN
GIT --> DEPLOY
SECRETS -.-> DEPLOY
DEPLOY -. "delegated policy management" .-> HS
DEPLOY -. "configure local renewal" .-> CERT
CERT -. "DNS-01" .-> CFAPI
end
DEPLOY -. "deploy and verify" .-> HA
DEPLOY -. "deploy and verify" .-> HB
HS -. "control and access policy" .-> MESH
subgraph MONITORING["Monitoring outside the mesh"]
direction LR
PROM["Prometheus<br/>one DNS-only target per edge<br/>verified TLS + authorized source + metrics token"]
GRAF["Grafana<br/>traffic, sessions, service paths<br/>origin probes and backend errors"]
ALERT["Optional alert rules and delivery<br/>must be configured separately"]
PROM --> GRAF
PROM -.-> ALERT
end
PROM -. "HTTPS /_edge/metrics" .-> HA
PROM -. "HTTPS /_edge/metrics" .-> HB
LIMITS["Failure boundaries<br/>Cloudflare bypasses an unavailable public edge<br/>HAProxy bypasses a failed per-service path when its peer is healthy<br/>a failed shared origin is still unavailable<br/>open video streams and WebSockets must reconnect"]
ORIGINS -.-> LIMITS
classDef public fill:#233d58,stroke:#7ab5dc,color:#f2f6ff
classDef edge fill:#284c44,stroke:#7bc8a7,color:#f2f6ff
classDef origin fill:#514132,stroke:#d6b48c,color:#f2f6ff
classDef ops fill:#43395c,stroke:#b4a0de,color:#f2f6ff
classDef monitor fill:#34495b,stroke:#9bbdd4,color:#f2f6ff
classDef note fill:#4c303a,stroke:#dd9ba7,color:#fff5f6
class WEB,MEDIA,DNS,CF,PROBE public
class HA,HB,SELECTA,SELECTB,GWA,GWB,ANA,ANB,MESH edge
class VPS,ROUTER,FW,NPM,APPS origin
class GIT,PLAN,DEPLOY,SECRETS,HS,CERT,CFAPI ops
class PROM,GRAF,ALERT monitor
class LIMITS note
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="660" viewBox="0 0 1000 660" role="img" aria-labelledby="title desc">
<title id="title">Two public edges, private application origins</title><desc id="desc">Cloudflare proxies browser applications. Media clients connect directly following DNS selection. Each edge has HAProxy and seven local Anubis instances, with private fallback to its peer. Shared origins are reached over the Tailscale overlay.</desc>
<rect width="1000" height="660" rx="16" fill="#101a2a"/>
<text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" x="500" y="36" font-size="23" font-weight="700">Two doors into the homelab</text>
<rect x="60" y="65" width="350" height="70" rx="10" fill="#243b59" stroke="#547294"/>
<text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="label" font-size="16" font-weight="600" x="235.0" y="94">Web visitors</text><text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="small" font-size="13" x="235.0" y="117">Browser applications through the Cloudflare proxy</text>
<rect x="590" y="65" width="350" height="70" rx="10" fill="#243b59" stroke="#547294"/>
<text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="label" font-size="16" font-weight="600" x="765.0" y="94">Media clients</text><text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="small" font-size="13" x="765.0" y="117">DNS selection, then direct HTTPS</text>
<rect x="60" y="175" width="350" height="70" rx="10" fill="#443751" stroke="#547294"/>
<text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="label" font-size="16" font-weight="600" x="235.0" y="204">Cloudflare Load Balancing</text><text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="small" font-size="13" x="235.0" y="227">One shared pool, two edge endpoints</text>
<rect x="234" y="134" width="2" height="40" fill="#96acc7"/><polygon fill="#96acc7" points="235,175 239,166 231,166"/>
<rect x="90" y="310" width="330" height="85" rx="10" fill="#243b59" stroke="#547294"/>
<text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="label" font-size="16" font-weight="600" x="255.0" y="339">edge-a</text><text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="small" font-size="13" x="255.0" y="362">HAProxy + per-service loopback Anubis</text>
<rect x="580" y="310" width="330" height="85" rx="10" fill="#243b59" stroke="#547294"/>
<text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="label" font-size="16" font-weight="600" x="745.0" y="339">edge-b</text><text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="small" font-size="13" x="745.0" y="362">HAProxy + per-service loopback Anubis</text>
<rect x="234" y="244" width="2" height="30" fill="#96acc7"/><rect x="234" y="274" width="20" height="2" fill="#96acc7"/><rect x="254" y="274" width="2" height="35" fill="#96acc7"/><polygon fill="#96acc7" points="255,310 259,301 251,301"/>
<rect x="349" y="244" width="2" height="30" fill="#96acc7"/><rect x="349" y="274" width="395" height="2" fill="#96acc7"/><rect x="744" y="274" width="2" height="35" fill="#96acc7"/><polygon fill="#96acc7" points="745,310 749,301 741,301"/>
<rect x="764" y="134" width="2" height="150" fill="#96acc7"/><rect x="764" y="284" width="50" height="2" fill="#96acc7"/><rect x="814" y="284" width="2" height="25" fill="#96acc7"/><polygon fill="#96acc7" points="815,310 819,301 811,301"/>
<rect x="689" y="134" width="2" height="125" fill="#96acc7"/><rect x="384" y="259" width="305" height="2" fill="#96acc7"/><rect x="384" y="259" width="2" height="50" fill="#96acc7"/><polygon fill="#96acc7" points="385,310 389,301 381,301"/>
<rect x="419" y="349" width="160" height="2" fill="#96acc7"/><polygon fill="#96acc7" points="580,350 571,346 571,354"/><polygon fill="#96acc7" points="420,350 429,354 429,346"/>
<text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="small" font-size="13" x="500" y="322">Private fallback</text><text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="small" font-size="13" x="500" y="380">Per service</text>
<rect x="254" y="394" width="2" height="45" fill="#96acc7"/><rect x="254" y="439" width="245" height="2" fill="#96acc7"/><rect x="499" y="439" width="2" height="30" fill="#96acc7"/><polygon fill="#96acc7" points="500,470 504,461 496,461"/>
<rect x="744" y="394" width="2" height="45" fill="#96acc7"/><rect x="499" y="439" width="245" height="2" fill="#96acc7"/><polygon fill="#96acc7" points="500,440 509,444 509,436"/>
<rect x="290" y="470" width="420" height="70" rx="10" fill="#214744" stroke="#547294"/>
<text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="label" font-size="16" font-weight="600" x="500.0" y="499">Tailscale overlay / Headscale</text><text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="small" font-size="13" x="500.0" y="522">Private routes and restricted access</text>
<rect x="394" y="539" width="2" height="35" fill="#96acc7"/><rect x="244" y="574" width="150" height="2" fill="#96acc7"/><rect x="244" y="574" width="2" height="20" fill="#96acc7"/><polygon fill="#96acc7" points="245,595 249,586 241,586"/>
<rect x="604" y="539" width="2" height="35" fill="#96acc7"/><rect x="604" y="574" width="150" height="2" fill="#96acc7"/><rect x="754" y="574" width="2" height="20" fill="#96acc7"/><polygon fill="#96acc7" points="755,595 759,586 751,586"/>
<rect x="70" y="595" width="350" height="60" rx="10" fill="#243b59" stroke="#547294"/>
<text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="label" font-size="16" font-weight="600" x="245.0" y="624">VPS application origins</text><text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="small" font-size="13" x="245.0" y="647">Private applications</text>
<rect x="580" y="595" width="350" height="60" rx="10" fill="#243b59" stroke="#547294"/>
<text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="label" font-size="16" font-weight="600" x="755.0" y="624">Home router → firewall → NPM</text><text text-anchor="middle" fill="#edf3fb" font-family="DejaVu Sans,sans-serif" class="small" font-size="13" x="755.0" y="647">Home applications, including Media</text>
</svg>

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 KiB

@@ -0,0 +1 @@
<svg id="architecture" width="3452.69580078125" xmlns="http://www.w3.org/2000/svg" class="flowchart" height="2687.199951171875" viewBox="0 0 3452.69580078125 2687.199951171875" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#architecture{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#architecture .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#architecture .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#architecture .error-icon{fill:#a44141;}#architecture .error-text{fill:#ddd;stroke:#ddd;}#architecture .edge-thickness-normal{stroke-width:1px;}#architecture .edge-thickness-thick{stroke-width:3.5px;}#architecture .edge-pattern-solid{stroke-dasharray:0;}#architecture .edge-thickness-invisible{stroke-width:0;fill:none;}#architecture .edge-pattern-dashed{stroke-dasharray:3;}#architecture .edge-pattern-dotted{stroke-dasharray:2;}#architecture .marker{fill:lightgrey;stroke:lightgrey;}#architecture .marker.cross{stroke:lightgrey;}#architecture svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#architecture p{margin:0;}#architecture .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#ccc;}#architecture .cluster-label text{fill:#F9FFFE;}#architecture .cluster-label span{color:#F9FFFE;}#architecture .cluster-label span p{background-color:transparent;}#architecture .label text,#architecture span{fill:#ccc;color:#ccc;}#architecture .node rect,#architecture .node circle,#architecture .node ellipse,#architecture .node polygon,#architecture .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#architecture .rough-node .label text,#architecture .node .label text,#architecture .image-shape .label,#architecture .icon-shape .label{text-anchor:middle;}#architecture .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#architecture .rough-node .label,#architecture .node .label,#architecture .image-shape .label,#architecture .icon-shape .label{text-align:center;}#architecture .node.clickable{cursor:pointer;}#architecture .root .anchor path{fill:lightgrey!important;stroke-width:0;stroke:lightgrey;}#architecture .arrowheadPath{fill:lightgrey;}#architecture .edgePath .path{stroke:lightgrey;stroke-width:2.0px;}#architecture .flowchart-link{stroke:lightgrey;fill:none;}#architecture .edgeLabel{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#architecture .edgeLabel p{background-color:hsl(0, 0%, 34.4117647059%);}#architecture .edgeLabel rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#architecture .labelBkg{background-color:rgba(87.75, 87.75, 87.75, 0.5);}#architecture .cluster rect{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:rgba(255, 255, 255, 0.25);stroke-width:1px;}#architecture .cluster text{fill:#F9FFFE;}#architecture .cluster span{color:#F9FFFE;}#architecture div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(20, 1.5873015873%, 12.3529411765%);border:1px solid rgba(255, 255, 255, 0.25);border-radius:2px;pointer-events:none;z-index:100;}#architecture .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#architecture rect.text{fill:none;stroke-width:0;}#architecture .icon-shape,#architecture .image-shape{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#architecture .icon-shape p,#architecture .image-shape p{background-color:hsl(0, 0%, 34.4117647059%);padding:2px;}#architecture .icon-shape rect,#architecture .image-shape rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#architecture .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#architecture .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#architecture :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#architecture .public rect{fill:#233d58!important;stroke:#7ab5dc!important;color:#f2f6ff!important;}#architecture .public polygon{fill:#233d58!important;stroke:#7ab5dc!important;color:#f2f6ff!important;}#architecture .public ellipse{fill:#233d58!important;stroke:#7ab5dc!important;color:#f2f6ff!important;}#architecture .public circle{fill:#233d58!important;stroke:#7ab5dc!important;color:#f2f6ff!important;}#architecture .public path{fill:#233d58!important;stroke:#7ab5dc!important;color:#f2f6ff!important;}#architecture .public tspan{fill:#f2f6ff!important;}#architecture .edge rect{fill:#284c44!important;stroke:#7bc8a7!important;color:#f2f6ff!important;}#architecture .edge polygon{fill:#284c44!important;stroke:#7bc8a7!important;color:#f2f6ff!important;}#architecture .edge ellipse{fill:#2Line truncated

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB