Setting Up Vaultwarden Behind Tailscale

Since being affected by layoffs recently, I took the time to brush up on my Nix skills. I wanted to set up a Vaultwarden server on my VPS to use as a backend for the Bitwarden password manager.

Even though people seem to host Vaultwarden publicly, I wanted to avoid that and hide it inside of my Tailscale network while still keeping it behind a Let’s Encrypt-issued certificate. It turned out to be quite easy to do using Nix’s ACME certificate-issuing features and a DNS-based challenge. Usually, everybody uses an HTTP-based challenge in which a Let’s Encrypt server tries to access a “well-known” URL and a expect a challenge response to be served there. Since only the owner of the domain controls what is being hosted, this is enough to prove ownership.

In a case where you want a valid certificate for a server not on the internet, this challenge does not work. Luckily, Let’s Encrypt supports a DNS-based challenge, where you put the challenge response in the DNS TXT records. This way, you can obtain a certificate even for a server which is not accessible from the internet.

How to Host Vaultwarden Behind Tailscale Using a Valid Certificate

You will need a Cloudflare DNS API token with permissions to alter your DNS zone. Under the hood, Nix uses Lego to solve ACME challenges. Lego supports multiple DNS providers, so there is a good chance that your provider is supported. I had to delegate the domain from Namecheap to Cloudflare because Namecheap does not give you API access unless you buy a lot of domains.

Then things are pretty straightforward:


# Mount the agenix encrypted secret with CLOUDFLARE_DNS_API_TOKEN
age.secrets."cloudflare-dns" = {
  file = ../../../secrets/cloudflare-dns.env.age;
  path = "/etc/cloudflare-dns.env";
  mode = "400";
  owner = "acme";
  group = "acme";
};


# Provision a signed TLS certificate outside of a webserver.
# The webserver needs to be configured to use this certificate instead of
# issuing its own
security.acme = {
  certs."vaultwarden.binarybuffer.com" = {
    dnsProvider = "cloudflare";
    environmentFile = "/etc/cloudflare-dns.env";

    # Make sure that the group for the certificate is set up correctly to
    # allow the webserver to access it
    group = config.services.nginx.group;
    postRun = ''
      # Set permissions on dir
      ${pkgs.acl}/bin/setfacl -m \
      u:nginx:rx \
      /var/lib/acme/vaultwarden.binarybuffer.com

      # Set permissions on key file
      ${pkgs.acl}/bin/setfacl -m \
      u:nginx:r \
      /var/lib/acme/vaultwarden.binarybuffer.com/*.pem
    '';

    # We need to specify manually which services to reload when
    # the certificate is changed 
    reloadServices = [
      "nginx"
    ];

  };
};

# Enable Vaultwarden
services.vaultwarden = {
  enable = true;
  backupDir = "/var/backup/vaultwarden";
  config = {
    DOMAIN = "https://vaultwarden.binarybuffer.com";
    SIGNUPS_ALLOWED = false;

    ROCKET_ADDRESS = "127.0.0.1";
    ROCKET_PORT = 8222;
    ROCKET_LOG = "critical";
  };
};

services.nginx.virtualHosts."vaultwarden.binarybuffer.com" = {
  # This is the key: it tells nginx to use the system-wide cert, and NOT to issue its own
  useACMEHost = "vaultwarden.binarybuffer.com";
  forceSSL = true;
  listenAddresses = [
    # The tailnet IP of the host; this ensures that the Vaultwarden is avaible only
    # on the tailnet and not on the internet. 
    "100.82.200.7"
  ];
  locations."/" = {
    proxyPass = "http://127.0.0.1:${toString config.services.vaultwarden.config.ROCKET_PORT}";
    proxyWebsockets = true;
  };
};

As you may have noticed, I am using agenix for secret management. You do not have to use it. You can just create a file with the token manually.

This generalises and you can host anything you like only on your tailnet with a Let’s Encrypt issued certificate which will be trusted by all browsers.