Second-layer ACME SSL with double-layer reverse proxy (NGINX, HAProxy)

Hi! I’m currently trying to figure out how to get NGINX on nas.example.org to terminate SSL and manage ACME. This is mostly a proof-of-concept right now — I can add proxies from NGINX to containers later. I only care about actual traffic on port 443 — so port 80 is only used for ACME HTTP-01 challenges. I could probably skip all this with a DNS-01 challenge but I really don’t want to rely on my DNS provider for that — I’m looking for a more portable solution.

When I disable SSL and ACME, this works fine. The principal error seems to come about with nixos-rebuild as Failed assertions: services.nginx.virtualHosts.<name>.enableACME requires a HTTP listener to answer to ACME requests. If I disable proxyProtocol on port 80, the error goes away but then NGINX doesn’t recieve packets from HAProxy.

Please help. How can I fix this?

As an aside, that listen attribute set list on nas.example.org is disgusting. Does anyone know how to map a: a // { proxyProtocol = true; } over the default generated value? Even more preferable, is there a way to skip manually defining the listen block altogether by setting proxy_protocol on all of the statements via a higher-level option?

Both systems are using Nix 2.28.5 and NixOS 25.05 — I’ll update soon, promise!

/etc/nixos/tunnel.nix - vps.example.org
{ config, pkgs, ...}:

{
  networking.firewall.allowedUDPPorts = [ 51820 ];
  networking.firewall.allowedTCPPorts = [ 80 443 ];

  networking.nat = {
    enable = true;
    enableIPv6 = true;
    externalInterface = "ens3";
    internalInterfaces = [ "tun0" ];
  };

  networking.wireguard.enable = true;
  networking.wireguard.interfaces.tun0 = {
    ips = [ "10.16.0.1/24" ];
    listenPort = 51820;
    privateKeyFile = "/root/tun0.key";

    peers = [
      {
        name = "nas.example.org";
        publicKey = "<nas-pubkey>";
        allowedIPs = [ "10.16.0.2/32" ];
        persistentKeepalive = 25;
      }
    ];
  };

  services.haproxy.enable = true;
  services.haproxy.config = ''
      log stdout format raw local0 info

    defaults
      log global
      mode tcp
      option http-server-close
      timeout client 10s
      timeout connect 5s
      timeout server 10s

    frontend haproxy_http
      bind *:80
      option tcplog

      default_backend nas_http

    frontend haproxy_https
      bind *:443
      option tcplog

      default_backend nas_https

    backend nas_http
      balance roundrobin
      server default 10.16.0.2:80 check send-proxy

    backend nas_https
      balance roundrobin
      server default 10.16.0.2:443 check send-proxy
  '';
}
/etc/nixos/tunnel-client.nix - nas.example.org
{config, pkgs, ...}:

{
  networking.firewall.allowedUDPPorts = [ 51820 ];
  networking.firewall.allowedTCPPorts = [ 80 443 ];
  networking.firewall.checkReversePath = "loose";

  networking.wireguard.enable = true;
  networking.wireguard.interfaces.tun0 = {
    ips = [ "10.16.0.2/32" ];
    listenPort = 51820;
    privateKeyFile = "/root/tun0.key";

    peers = [
      {
        name = "vps.example.org";
        publicKey = "<vps-pubkey>";
        allowedIPs = [ "10.16.0.0/16" ];
        endpoint = "vps.example.org:51820";
        persistentKeepalive = 25;
      }
    ];
  };

  services.nginx.enable = true;
  services.nginx.recommendedProxySettings = true;
  services.nginx.virtualHosts."test.example.org" = {
    addSSL = true
    enableACME = true;
    default = true;
    listen = [
      { addr = "0.0.0.0"; port = 80; proxyProtocol = true; }
      { addr = "0.0.0.0"; port = 443; ssl = true; proxyProtocol = true; }
      { addr = "[::0]"; port = 80; proxyProtocol = true; }
      { addr = "[::0]"; port = 443; ssl = true; proxyProtocol = true; }
    ];
    locations."/" = {
      return = "200 'Hello World!'";
    };
  };

  security.acme.acceptTerms = true;
  security.acme.defaults.email = "web+acme@example.org";
  #users.users.nginx.extraGroups = [ "acme" ];
}

I have a slightly different setup but maybe it helps: nixos/modules/services/haproxy/default.nix at master · Nebucatnetzer/nixos · GitHub

Thank you so much! I did (mostly) get it working. Now I’m just running into a different problem: proxy_protocol doesn’t use the actual client IP — $remote_addr always seems to be 10.16.0.1, even with set_real_ip_from.

Setting option transparent in HAProxy seems to just spam HAProxy connecting to itself.

That said, this all is technically functional — if not ideal. If you have any ideas I’d be really grateful to hear them.

To clarify for anyone reading this thread, here’s my current NGINX and HAProxy configuration (I split each file into wireguard.nix and nginx.nix and haproxy.nix respectively.)

/etc/nixos/haproxy.nix - vps.example.org
{ config, pkgs, ...}:

{
  networking.firewall.allowedTCPPorts = [ 80 443 ];

  boot.kernel.sysctl = { "net.ipv4.ip_nonlocal_bind" = true; };

  services.haproxy.enable = true;
  services.haproxy.config = ''
    defaults
      log stdout format raw local0 info
      option tcplog
      option http-server-close
      timeout client 10s
      timeout connect 5s
      timeout server 10s

    frontend http
      bind *:80
      mode http

      default_backend nas_http

      # if unencrypted: redirect to fe_https w/ 301
      redirect scheme https code 301 if !{ ssl_fc }

    frontend https
      bind *:443
      mode tcp

      tcp-request inspect-delay 5s
      tcp-request content accept if { req_ssl_hello_type 1 }

      default_backend nas_https

    backend nas_http
      mode http
      balance roundrobin
      #option transparent
      server default 10.16.0.2:80 check

    backend nas_https
      mode tcp
      balance roundrobin # incompatible with transparent
      option ssl-hello-chk
      #option transparent
      server default 10.16.0.2:443 check send-proxy-v2
  '';
}
/etc/nixos/nginx.nix - nas.example.org
{config, pkgs, ...}:

{
  networking.firewall.allowedTCPPorts = [ 80 443 ];

  services.nginx.enable = true;
  services.nginx.recommendedProxySettings = true;

  services.nginx.virtualHosts."test.example.org" = {
    addSSL = true;
    enableACME = true;
    listen = [
      { addr = "0.0.0.0"; port = 80; }
      { addr = "0.0.0.0"; port = 443; ssl = true; proxyProtocol = true; }
      { addr = "[::0]"; port = 80; }
      { addr = "[::0]"; port = 443; ssl = true; proxyProtocol = true; }
    ];
    extraConfig = ''
      set_real_ip_from 10.16.0.1;
    '';
    locations."/" = {
      return = "200 'Hello World!\\nIP: \$remote_addr'";
      extraConfig = ''
        default_type text/plain;
      '';
    };
    locations."/robots.txt" = {
      extraConfig = ''
        rewrite ^/(.*)  $1;
        return 200 "User-agent: *\nDisallow: /";
      '';
    };
  };

  security.acme.acceptTerms = true;
  security.acme.defaults.email = "web+acme@example.org";
  users.users.nginx.extraGroups = [ "acme" ];
}