How to Implement Dynamic DNS with Nginx

How to Implement Dynamic DNS with Nginx

I ran into this problem while managing a homelab setup that also happened to serve a couple of low-traffic production sites for a side project. My ISP assigns a dynamic IP address that changes every few days, and Nginx, by design, resolves upstream hostnames only once at startup and caches that resolution indefinitely unless you tell it otherwise. That meant every time my home IP changed, or every time a backend container behind a dynamic hostname got a new address, Nginx kept routing traffic to a stale IP until I manually reloaded it. That’s a fragile way to run anything, so I had to figure out how to make Nginx actually respect DNS TTLs and re-resolve hostnames dynamically. This article walks through exactly how I solved it.

To be clear about terminology up front: “Dynamic DNS with Nginx” can mean two related but distinct things, and I’ll cover both:

  1. Making Nginx re-resolve upstream hostnames dynamically at runtime (instead of caching them forever), which matters when your backend’s IP address can change — common with Dynamic DNS (DDNS) services, cloud auto-scaling groups, or containerized environments.
  2. Using Nginx alongside a Dynamic DNS client so that your server’s own public-facing DNS record stays updated when your public IP changes, which matters for self-hosted setups behind residential or dynamic-IP connections.

I’ll walk through both, since in practice they often go together.

Why This Is Tricky With Nginx

By default, when Nginx starts up (or reloads), it resolves any hostname you reference in a proxy_pass or upstream directive exactly once, using the system resolver, and then holds onto that IP address for the lifetime of the worker process. This is fine for static infrastructure. It’s a real problem when:

  • You’re pointing at a backend behind a Dynamic DNS hostname (like a no-ip.com or duckdns.org address) that changes periodically.
  • You’re running Docker containers where the backend’s internal IP can change on restart.
  • You’re using cloud provider auto-scaling where instances come and go, each with new IPs behind a load balancer hostname.
  • Your own server sits behind a residential ISP connection with a non-static public IP, and you need external DNS to track that changing address.

Part 1: Making Nginx Re-Resolve Upstream Hostnames Dynamically

The Core Problem

If you write a config like this:

server {
    location / {
        proxy_pass http://backend.duckdns.org:8080;
    }
}

Nginx resolves backend.duckdns.org once, at startup or reload, and then keeps using that cached IP indefinitely — regardless of the DNS record’s actual TTL — until you run nginx -s reload again. If the backend’s IP changes in between reloads, Nginx keeps sending traffic to the old, now-incorrect address, and you start seeing connection failures or timeouts with no obvious cause.

The Fix: Using a Variable with the resolver Directive

The trick to forcing Nginx to re-resolve a hostname on every request (or on a TTL-respecting basis) is to use a variable in the proxy_pass directive instead of a static hostname, combined with an explicit resolver directive. Nginx only performs dynamic (per-request) resolution when the hostname comes from a variable — a literal hostname is resolved once and cached for the worker’s lifetime.

Here’s a working configuration:

resolver 8.8.8.8 8.8.4.4 valid=30s;
resolver_timeout 5s;

server {
    listen 80;
    server_name example.com;

    location / {
        set $backend_host "backend.duckdns.org";
        proxy_pass http://$backend_host:8080;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Let’s break this down:

  • resolver 8.8.8.8 8.8.4.4 valid=30s; — this tells Nginx to use Google’s public DNS resolvers and to treat cached DNS answers as valid for only 30 seconds, regardless of the record’s actual TTL. You can point this at your own internal DNS resolver instead if you have one (e.g., resolver 127.0.0.53 valid=30s; on systems using systemd-resolved, or your router’s IP).
  • resolver_timeout 5s; — how long Nginx will wait for a DNS response before giving up.
  • set $backend_host "backend.duckdns.org"; combined with proxy_pass http://$backend_host:8080; — using a variable here is what triggers Nginx’s dynamic resolution behavior. Had I written proxy_pass http://backend.duckdns.org:8080; directly with a literal hostname, Nginx would resolve it once and cache the result until the next reload, ignoring the resolver directive’s TTL entirely.

This distinction — variable versus literal hostname — is the single most important detail in this whole setup, and it’s the part most tutorials gloss over or get wrong.

Choosing Your Resolver Wisely

You don’t have to use public DNS. If you’re running an internal DNS server (like dnsmasq, CoreDNS, or your router’s built-in resolver) that’s aware of your Dynamic DNS hostname faster than public resolvers might propagate, point resolver at that instead:

resolver 192.168.1.1 valid=15s;

Shorter valid times mean faster reaction to IP changes but more frequent DNS lookups, which adds a small amount of latency to each cache-expired request and additional load on your resolver. I generally use somewhere between 15 and 60 seconds depending on how frequently the backend’s IP actually changes.

Verifying the Config

Test syntax:

sudo nginx -t

Reload:

sudo systemctl reload nginx

To verify dynamic resolution is actually happening, I like to temporarily lower the DNS TTL and watch Nginx’s error log at debug level (you’ll need a debug build or error_log ... debug; in your config) to see resolution events, or more practically, just change the backend’s IP and confirm Nginx picks it up within the valid window without a manual reload.

A simpler real-world verification: point your Dynamic DNS hostname at a test backend, curl through Nginx, then change what the hostname resolves to (update the DDNS record), wait past your valid TTL, and curl again. If the second request reaches the new backend without you touching Nginx, it’s working.

Part 2: Using Nginx Alongside a DDNS Client for Your Own Server

If your Nginx server itself sits behind a dynamic public IP (typical for home labs, small self-hosted setups, or anywhere without a static IP from your ISP), you need something updating your external DNS record whenever your IP changes. Nginx itself doesn’t do this — you need a DDNS client running alongside it — but the two work together to make the whole setup functional.

Step 1: Choose a Dynamic DNS Provider

Popular free and low-cost options include DuckDNS, No-IP, FreeDNS, and Cloudflare (using its API with a small script or a tool like ddclient). I’ll use DuckDNS as an example since it’s simple and free.

Step 2: Install and Configure a DDNS Update Client

For DuckDNS, this is often just a cron job hitting their update URL:

mkdir -p ~/duckdns
cat > ~/duckdns/duck.sh << 'EOF'
#!/bin/bash
echo url="https://www.duckdns.org/update?domains=yoursubdomain&token=your-token-here&ip=" | curl -k -o ~/duckdns/duck.log -K -
EOF
chmod +x ~/duckdns/duck.sh

Add a cron entry to run it every 5 minutes:

crontab -e
*/5 * * * * ~/duckdns/duck.sh >/dev/null 2>&1

For a more general-purpose tool that supports many providers, ddclient is a solid choice:

sudo apt install ddclient -y

During installation (or by editing /etc/ddclient.conf afterward), configure your provider, username/token, and hostname. A typical config for a generic provider looks like:

protocol=dyndns2
use=web, web=checkip.dyndns.org/, web-skip='IP Address'
server=members.dyndns.org
login=your-username
password='your-password'
yourhostname.dyndns.org

Enable and start the service:

sudo systemctl enable --now ddclient

Step 3: Point Nginx’s server_name at Your Dynamic Hostname

server {
    listen 80;
    server_name yourhostname.duckdns.org;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
    }
}

Since this is your own server’s DNS record (not an upstream backend), Nginx doesn’t need to re-resolve anything here — server_name is just a matching pattern against the incoming Host header, not something Nginx looks up. The DDNS client is doing all the work of keeping the DNS record itself accurate.

Step 4: Get TLS Working With a Changing Hostname

Since the hostname itself is stable (only the underlying IP changes), Let’s Encrypt via Certbot works normally:

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourhostname.duckdns.org

Certbot will handle the certificate issuance and auto-configure the HTTPS server block. Renewal happens automatically via the systemd timer or cron job Certbot installs.

Combining Both Parts

In a homelab scenario, I typically run both together: Nginx re-resolves internal or external backend hostnames dynamically using the variable + resolver technique (Part 1) for any backend services behind their own DDNS names, while a DDNS client keeps my server’s own public-facing hostname pointed at my current IP (Part 2). This means the whole chain stays self-healing without manual reloads, even when both ends of the connection have changing addresses.

Testing the Full Setup

  1. Confirm the DDNS client is updating successfully — check its logs (~/duckdns/duck.log or journalctl -u ddclient) for success messages.
  2. Confirm your public hostname resolves to your current IP: dig +short yourhostname.duckdns.orgcurl -s ifconfig.me These two should match.
  3. Confirm Nginx is serving traffic correctly through the hostname: curl -I https://yourhostname.duckdns.org
  4. For upstream dynamic resolution, force an IP change on the backend hostname and confirm Nginx picks it up without a reload, as described earlier.

Troubleshooting Common Issues

Problem: Nginx still routes to an old IP after the backend’s DNS changed.

Double-check you’re using a variable in proxy_pass, not a literal hostname. This is the single most common mistake. Also confirm your resolver directive is actually in scope (it needs to be in the http, server, or location context that applies).

Problem: nginx -t fails with a resolver-related error.

Make sure the resolver directive is syntactically correct and that the DNS servers you specified are actually reachable from the Nginx host. Test with dig @8.8.8.8 example.com from the same server.

Problem: DDNS record isn’t updating.

Check the DDNS client’s logs for authentication errors or rate limiting. Many free DDNS providers rate-limit updates; hitting the update URL too frequently can result in temporary bans. Every 5 minutes is generally safe; every few seconds is not.

Problem: TLS certificate renewal fails after an IP change.

Let’s Encrypt’s HTTP-01 challenge requires your DNS to point at the IP where Certbot is running the challenge at the time of renewal. If your DDNS record is stale during a renewal attempt, the challenge will fail. Make sure your DDNS update interval is short enough (5 minutes or less) relative to how frequently your IP actually changes, so records stay accurate.

Security Considerations

  • Restrict your resolver directive to trusted DNS servers only. A compromised or malicious resolver could redirect your backend traffic elsewhere — this is a real attack surface, not a theoretical one.
  • Keep DDNS credentials (tokens, passwords) out of version control and readable only by the user running the update script (chmod 600 on any credentials file).
  • If your dynamic hostname is public, remember that anyone can query DNS and eventually enumerate what’s running behind it. Combine this setup with proper firewalling and, where appropriate, authentication at the Nginx layer for anything sensitive.
  • Consider using DNS-over-HTTPS or DNS-over-TLS resolvers if you’re concerned about DNS query privacy or tampering, though this requires additional configuration beyond Nginx’s native resolver support.

Performance Tips

  • Don’t set valid on the resolver directive too low — every cache expiration triggers a real DNS lookup, adding latency to that request. I’ve found 30 seconds to be a reasonable middle ground for most homelab and small-scale use cases; go longer (60-300s) if your backend IP genuinely changes infrequently.
  • If you have many locations proxying to the same dynamic backend, define the resolver once at the http block level rather than repeating it everywhere.
  • Monitor DNS query volume against your resolver if you’re running many Nginx instances with short TTLs — it can add up.

Real-World Use Cases

  • Homelab reverse proxies routing to backend services running behind residential DDNS hostnames.
  • Hybrid cloud setups where an on-premises Nginx instance proxies to a cloud backend whose IP can shift due to instance replacement.
  • Containerized environments where Nginx runs outside a container orchestrator but needs to reach services whose IPs change on redeploy, referenced via a service discovery hostname.
  • IoT and remote-monitoring dashboards self-hosted from a home connection, made reachable externally via a DDNS hostname and reverse-proxied through Nginx for TLS termination.

Best Practices Summary

  • Always use a variable (not a literal hostname) in proxy_pass if you need Nginx to respect changing DNS records.
  • Set an explicit resolver directive with a sensible valid TTL rather than relying on defaults.
  • Run a dedicated DDNS client for keeping your own server’s public hostname accurate; don’t try to make Nginx do this job, since it isn’t designed for it.
  • Keep your DDNS update interval frequent enough to avoid TLS renewal failures and stale routing.
  • Secure your DDNS credentials and restrict resolver trust to servers you actually trust.

Using an Alternative: stream Block for Non-HTTP Traffic

Everything above focuses on http-level proxying, but if you’re forwarding non-HTTP traffic (say, a raw TCP service behind a dynamic hostname), the same variable-plus-resolver trick applies within the stream context:

stream {
    resolver 8.8.8.8 valid=30s;

    server {
        listen 2222;
        proxy_pass backend.duckdns.org:22;
    }
}

Interestingly, stream‘s proxy_pass behaves a bit differently from the http module’s version regarding dynamic resolution, and behavior has evolved across Nginx versions — always check the changelog and documentation for your specific installed version if this matters for your setup, since older versions had more limited dynamic resolution support in the stream context compared to http.

A Note on systemd-resolved and Local Resolvers

On many modern Ubuntu and Debian systems, systemd-resolved runs a local stub resolver on 127.0.0.53. You can point Nginx’s resolver directive at this local stub instead of a public DNS provider:

resolver 127.0.0.53 valid=15s;

This has the advantage of respecting whatever DNS configuration your system already uses (including any local /etc/hosts overrides handled by NSS, though be aware Nginx’s resolver directive uses actual DNS queries and does not read /etc/hosts the way system utilities do), and it avoids depending on an external service for something as fundamental as name resolution. The tradeoff is that if systemd-resolved itself is misconfigured or briefly unavailable, your dynamic resolution breaks along with it, so it’s worth testing this path specifically rather than assuming it “just works” the same way public resolvers would.

Frequently Asked Questions

Will this work with a free DDNS provider that has short-lived hostnames?

Yes, this pattern works with essentially any DDNS provider — DuckDNS, No-IP, FreeDNS, Dynu, and so on — since the mechanism relies purely on standard DNS resolution behavior, not anything provider-specific. The main variable is how quickly the provider’s own DNS infrastructure propagates updates after your DDNS client pushes a new IP, which affects how short you can realistically set your resolver valid= window before you’re just adding pointless extra lookups.

Does this increase my server’s DNS query volume noticeably?

For a single Nginx instance with one or two dynamically resolved backends and a reasonable valid window (15-60 seconds), the added query volume is trivial — we’re talking maybe one or two extra DNS lookups per minute per backend, not per request. It only becomes a real consideration at very high scale with many distinct dynamically-resolved upstreams and an aggressively short TTL.

What happens if DNS resolution fails temporarily?

If Nginx can’t resolve the hostname when it needs to (cache expired, new lookup fails), the request to that upstream will fail, typically surfacing as a 502 to the client. This is why I’d recommend keeping a reliable secondary resolver in your resolver directive (like resolver 8.8.8.8 8.8.4.4 valid=30s; with two providers) rather than a single point of failure.

Can I combine this with Nginx’s upstream health checks?

Yes, though open-source Nginx’s health checking is passive (max_fails/fail_timeout) as covered in the load balancing article in this series. Combining dynamic resolution with an upstream block’s passive health checks gives you a setup that both re-resolves changing IPs and automatically avoids currently-unreachable backends.

Wrapping Up

Dynamic DNS and Nginx aren’t natural friends by default — Nginx’s caching behavior is a sensible choice for static infrastructure but works against you the moment your backend or your own public IP starts moving around. The fix isn’t complicated once you know the trick: use a variable in proxy_pass, pair it with an explicit resolver directive, and run a proper DDNS client alongside it for your own server’s hostname. Once that’s in place, the whole system becomes self-healing, and you stop having to manually reload Nginx every time your ISP decides to hand you a new IP address.

Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Nginx with Ruby on Rails and Puma

How to Set Up Nginx with Ruby on Rails and Puma

Next Post
How to Set Up Nginx as a Reverse Proxy for Elasticsearch

How to Set Up Nginx as a Reverse Proxy for Elasticsearch

Related Posts