Technology, open source, unsolicited opinions & digital sovereignty

« Too Simple To Fail: Marrying Nomad, Caddy, and Wireguard »

  • 4 February, 2022
  • 3,248 words
  • 18 minutes read time

My little lab can afford some experimental allowances given that I’ll never (hopefully) breach the “thousands of hosts” mark. One experiment that paid off recently was ditching Traefik v1 for a hybrid setup that uses Nomad, consul-template, Caddy, and wireguard in order to provide the HTTP routing layer for my services.

I think it’s an interesting solution, and it has proven a) particularly resilient and b) very easy to maintain and extend.

The Problem

When you run a gaggle of containers in a runtime like kubernetes, one of the most fundamental needs is the ability to route incoming traffic to the right workloads. My browser wants to use my grafana installation, so I need to hit an IP address, that endpoint needs to terminate TLS and reverse proxy to a container running on the backend that may or may not have recently migrated due to fluctuations in resources constraints or cluster member availability.

For folks on something like GKE, this is pretty hands-off. Depending on whether you decide to use something like the Nginx ingress controller or just rely on GKE-native resources entirely, you hand k8s a block of YAML asking it to route requests for Host: my.service.app to a certain set of listening containers, and GCP hastily assembles the requisite pieces to make it happen. A load balancer appears, it probably handles ad-hoc certificate provisioning, most of it feels like magic (if you’re used to more manual methods like a geriatric devops individual such as myself).

Things are different if you’re doing it yourself, or in the case of a home lab, reliant on physical hardware and can’t rely on the magic of highly available load balancers that offer an endpoint and let you carry on. Here are some considerations to factor in:

There are even more considerations to make; suffice to say that it’s sort of a sticky problem. Many of these problems sort of disappear in a more traditional, single-host environment, since that just requires pointing nginx at something like, installing acme on a cron job, and talking to one host. More than one machine but less than fully-managed Cloud solutions is the sweet sore spot.

Note: to comments like “why would you build such a large lab that requires this amount of work?”, please be aware: this is my hobby, and I enjoy it. Yes, I engage in devops labor in my free time. I do not have brain damage.

The Ghost of Infrastructure Past

In the heady days of Traefik v1, you could solve this pretty elegantly. Traefik can glean a catalog of running services from Consul, manage Let’s Encrypt certificates natively and store them in a key/value store like Consul which permits for >1 instances for high availability because certificate keys and data persist cluster-wide instead of within a single directory. This is all well and good - you can now hit the Traefik endpoint and get a dynamically-updating reverse proxy that’ll manage certs for you, but:

While Traefik configuration is pretty hands-off, you’ll need to use something else if you want feature parity but want to stay up-to-date and not pay enterprise prices for a hobby lab.

Dear reader, this is the situation I found myself in recently: still running an aged Traefik v1 deployment without sufficient reason to justify paying for whatever pound of flesh Enterprise Tier Traefik demanded.


I’ve tried to steer my lab toward less-complex solutions where I can (and yes, I see the irony about doing this in a homelab with dozens of machines present). For example, rather than running something like kubernetes in my lab, I operate Nomad instead, which is much simpler to wrap your head around.1 In this case, two candidates came to mind when thinking about “load balancing” and “secure backend communication”: Caddy and wireguard, respectively.

Caddy is probably best-in-class when it comes to ad-hoc certification provisioning - they were among the first to do so by default, and the configuration is tremendously simpler than something like Apache (or nginx) by comparison. I mean, behold this sample reverse proxy configuration and tell me you don’t love looking at it:

reverse_proxy localhost:5000

This is literally a valid Caddy configuration for a reverse proxy with TLS (assuming http-01 can work). It’s honestly beautiful (and marginally less complex than this).

If you’re privy to it, you know that wireguard is the best thing since sliced bread, and probably better than sourdough toast. I’ve setup a few networks and it does for private networking what ssh does for remote access; it’s simple and effective. The “secure backend communication” problem made me consider how wireguard might come into play as an orchestration-agnostic solution.

Note: Consul connect does solve some of these problems, but I’m looking for fairly dynamic proxy configuration, and most Nomad examples require some extra configuration for workloads to communicate with the frontend proxy. Moreover, this also requires a sidecar Envoy proxy, which I’d like to avoid on my slim ARM SBCs. But credit is due to Hashicorp for providing this for those who need it.


Phew, that’s enough set-up. Let’s dig into the meat.

Networking Plane

Remember when I said that I can build things that don’t scale? wesher is an auto-assembling wireguard mesh tool that works like this:

And bam, encrypted private network mesh. The “doesn’t scale part” is that, if addresses are derived from hostnames, there’s an extant risk of address collisions if names “hash” to the same value in a subnet. Granted, it’s not huge, but it’s there. But I’m not going anywhere above thirty hosts! Or fourty maybe. Fifty.

To demonstrate this, consider the only two requisite configuration files: the systemd unit,

Description=wesher - wireguard mesh builder


WantedBy = multi-user.target

…and the environment file:


We set up wesher on each node in the Nomad cluster and the host that runs the reverse proxy. As with some of my other technology choices, there are a variety of solutions you could probably pick here2 - and it doesn’t look like wesher is under super-active development, but again - it’s simple, so there’s not a ton that can go wrong.

Container Orchestration

There’s a minor change required for Nomad workloads to ensure that containers join the encrypted mesh, and it looks like this:

network {
  port "http" {
    host_network = "mesh"

Which instructs Nomad to join the workload’s container namespace to this network I configure on each node’s Nomad configuration:

"host_network": {
    "mesh": {
        "interface": "wgoverlay"

Not a lot, but it ensures that communication in and out of workloads happen over an encrypted connection. Moreover, Nomad factors this in when adding services to Consul’s catalog, so once this change takes effect, we know about service endpoints and their private wireguard IPs as well, so whatever system we’re reverse proxying with just uses a different IP when assembling its reverse proxy routes.

You end up with these interfaces scattered among every cluster member:

# ip addr show wgoverlay
5: wgoverlay: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN group default
    inet brd scope global wgoverlay
       valid_lft forever preferred_lft forever

Time for caddy.

Caddy doesn’t natively know how to build a routing configuration like Traefik can (though Matt has commented about it). Fortunately, building on Caddy’s simple syntax can make for a very concise consul-template configuration. The strategy becomes: run consul-template on the HTTP box, generate an ad-hoc Caddyfile, and pretend that Caddy is just creating all of this dynamically for us, populated off of consul’s service catalog.

What does this look like? Here’s a slightly-cleaned-up version of what I ended up writing:

{{- range services -}}
    {{- range service .Name -}}
        {{- if (.Tags | contains "caddy") -}}
            {{- scratch.MapSetX "vhosts" .Name true -}}
            {{- if .Tags | contains "public" }}
                {{- scratch.MapSet "vhosts" .Name false -}}
            {{- end -}}
        {{- end -}}
    {{- end -}}
{{- end -}}
    http_port 80
    https_port 443
    acme_ca "https://acme-v02.api.letsencrypt.org/directory"
    storage "consul" {
        address ""
        prefix "caddytls"

https://*.example.com {
{{ range $vhost, $private := scratch.Get "vhosts" }}
    @{{ $vhost }} host {{ $vhost }}.example.com
    handle @{{ $vhost }} {
{{- if $private }}
        @blocked not remote_ip
        respond @blocked "Access denied" 403
{{- end }}

{{- range services }}
        {{- range service .Name }}
            {{- if (and (.Tags | contains "caddy") (eq .Name $vhost)) }}
                {{- if index .ServiceMeta "path" }}
        reverse_proxy {{ index .ServiceMeta "path" }} http://{{ .Address }}:{{ .Port }}
                {{- else }}
        reverse_proxy http://{{ .Address }}:{{ .Port }}
                {{- end }}
            {{- end }}
        {{- end }}
    {{- end }}
{{ end }}
    handle {

    tls {
        dns tylerjl-route53

Lines 1 through 10 establish a go template variable called vhosts that map a virtual host name to a boolean indicating whether or not it should be considered private; that is, whether it should only permit local traffic (in case this proxy receives port-forwarded traffic through a router). 11 through 19 are Caddy settings, including a directive to store Let’s Encrypt cert data in Consul, which makes this proxy stateless. Line 21 asks for a wildcard to serve up vhosts for our domain, and then 22 through 42 loop through services present in consul’s catalog and setup reverse proxies for each. You could make this much prettier if consul-template included sprig libraries, but those functions aren’t there yet.

You can see from line 33 that it’s pretty easy to make this system flexible; I can add something like the following to a Nomad job definition in order to ask, for example, that a certain path get matched in order to hit a specific proxy.

meta {
  path = "/subroute"

Some more notes: this configuration requires two plugins for Caddy:

Magic! When consul-template kicks out a new Caddyfile, it looks sort of like this:

    http_port 80
    https_port 443
    acme_ca "https://acme-v02.api.letsencrypt.org/directory"
    storage "consul" {
        address ""
        prefix "caddytls"

https://*.example.com {
    @whoami host whoami.example.com
    handle @whoami {
        @blocked not remote_ip
        respond @blocked "Access denied" 403

    handle {

    tls {
        dns tylerjl-route53

…and with this Caddy will manage certificate provision and renewal along with modern expectations you might have for a reverse proxy like support for websockets. At ~50 lines it’s pretty easy to adapt the template as well if you had the need for additional functionality, like adding/removing headers.


Overall this setup has replaced my previous need for my aging Traefik v1 deployment, and I’ve both torn down and stood up new services in my Nomad cluster and watched as vhosts “magically” appear in my private LAN domain (that is, I nomad run thing.nomad, then a minute later start using it at https://thing.example.com).

In addition, there are a few new “features” I’ve begun to use in my homelab.

I still have a number of “legacy” services that I don’t operate in a container scheduler (for example, I run Transmission on a singular host). Instead of running a bespoke nginx reverse proxy on that individual host, I now register the listening Transmission web port in consul and let Caddy serve as the central HTTP endpoint for any web services in my homelab. By dropping a systemd service like this onto the host, a route appears in Caddy when the host comes up and the service starts, and disappears when it stops.

Description=consul catalog registrar for transmission
Requisite=consul.service transmission.service wesher.service
After=consul.service transmission.service wesher.service
BindsTo=transmission.service wesher.service

ExecStart=/usr/bin/env sh -c '\
     addr=$(ip -4 addr show wgoverlay | awk \'$1 == "inet" { print $4; }\'); \
     consul services register -name=transmission -address=$addr -tag=caddy -port=9091 '
ExecStop=/usr/bin/consul services deregister -id=transmission


I really like this because I only have to concern myself with configuring (and securing) one reverse proxy configuration, and it becomes very easy to “expose” running services on any host in one place with all the necessary TLS pieces in place and ready.

This also offers a lot of flexibility in terms of the location of the HTTP endpoint in my lab. For example, this consul-template configuration can dynamically update my dnsmasq instance to point DNS wherever Caddy may be running at any given time - for example, if Nomad moves it to another host as I’m performing maintenance on the machine it was running on before.

template {
  contents = <<EOF
{{- range services }}{{- if (.Tags | contains "caddy") }}
address=/{{ .Name }}.example.com/
{{- end }}
{{- end }}

  command = "/usr/bin/systemctl reload dnsmasq"
  destination = "/etc/dnsmasq.d/web.conf"


There’s surely a myriad of ways you could solve this, but choosing a combination of consul, wireguard, nomad, and caddy resolves the outstanding concerns (dynamic updates, TLS management, cluster-capable, secure backend communication) with individually simple parts and the ability to extend into other systems with relative ease.

  1. I understand that, because the Container Scheduler wars have largely been fought and won by kubernetes, there’s bound to be all sorts of takes about this. Believe what you will, but as someone who has operated both systems, the two aren’t really comparable along the complexity axis. If you’re a kubernetes devotee, do not feel hurt, you get more features. But Nomad is the more “assemble small pieces” system. 

  2. I know about tailscale! It sounds great! But I also like to segment off portions of my lab from external dependencies when possible, and Tailscale (understandably) runs a central, remote endpoint to operate all the bits and pieces that permit discovery to happen. Wesher isn’t as fully featured, but literally all you need is two wesher process to make the mesh work, and you’re done.