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.