The Setup
My home server is behind a residential connection with a dynamic public IP. I want subdomains like jellyfin.home.example.com to hit the right services over HTTPS with valid certificates. On a traditional distro, this is a multi-tool dance: certbot cron jobs, nginx configs in /etc/nginx/sites-available, a separate DDNS client, and firewall rules managed with ufw or iptables directly.
On NixOS, all of this is declared in one place and stays consistent across rebuilds.
nginx Virtual Hosts
NixOS wraps nginx configuration in services.nginx. You declare virtual hosts as an attribute set keyed by domain name:
services.nginx = {
enable = true;
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
recommendedProxySettings = true;
virtualHosts = {
"jellyfin.home.example.com" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:8096";
proxyWebsockets = true;
};
};
"nextcloud.home.example.com" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:8080";
};
};
};
};
recommendedTlsSettings enables TLS 1.2+, disables weak ciphers, and sets HSTS headers. recommendedProxySettings sets the standard X-Real-IP, X-Forwarded-For, and Host headers that proxied apps need to see the real client IP. Don't skip those; you'll get confusing auth behavior in Nextcloud otherwise.
ACME and Let's Encrypt
enableACME = true on a virtualHost wires that domain into NixOS's ACME subsystem. You configure the ACME module once:
security.acme = {
acceptTerms = true;
defaults.email = "you@example.com";
};
NixOS creates a systemd service (acme-jellyfin.home.example.com.service) and timer for each domain that has ACME enabled. Certificates land in /var/lib/acme/<domain>/ and nginx is reloaded automatically after renewal.
The default challenge type is HTTP-01, which means port 80 needs to be reachable from the public internet on that domain. If you're using a DNS challenge (useful when port 80 isn't open or you want wildcard certs), specify it per-domain:
security.acme.certs."home.example.com" = {
domain = "*.home.example.com";
dnsProvider = "cloudflare";
credentialFiles = {
CF_DNS_API_TOKEN_FILE = config.age.secrets.cloudflare-api-token.path;
};
group = "nginx";
};
Then reference this cert in your virtual hosts:
"jellyfin.home.example.com" = {
useACMEHost = "home.example.com";
forceSSL = true;
...
};
This gets you a wildcard cert via DNS challenge, so you're not punching port 80 through your router for every domain.
DDNS
For dynamic DNS, I use the ddclient service. NixOS has a native module:
services.ddclient = {
enable = true;
interval = "10min";
protocol = "cloudflare";
zone = "example.com";
domains = [ "home.example.com" ];
username = "token";
passwordFile = config.age.secrets.cloudflare-api-token.path;
};
ddclient checks your public IP every 10 minutes and updates the DNS A record if it's changed. passwordFile uses agenix (covered in post 5) to avoid putting the token in plain text in your config.
If you're using a different DNS provider, check the ddclient docs for the right protocol and zone settings. Most major providers are supported.
Firewall Rules
NixOS uses networking.firewall which wraps nftables (or iptables on older kernels). The default posture is deny-all inbound, which is what you want:
networking.firewall = {
enable = true;
allowedTCPPorts = [ 80 443 ];
allowedUDPPorts = [];
};
That's it for public-facing traffic. Port 80 is needed for ACME HTTP-01 challenges; port 443 is your HTTPS traffic. Everything else stays closed.
If you're running services that only need to be reachable on your LAN (a local dashboard, Prometheus, etc.), use interface-specific rules:
networking.firewall.interfaces."enp3s0" = {
allowedTCPPorts = [ 9090 3000 ]; # Prometheus and Grafana, LAN only
};
This beats setting up a separate firewall tool, and since it's in your config it doesn't get forgotten or accidentally rolled back during a package upgrade.
Next up: running self-hosted services the NixOS way.