Building a "what's my ip" service with Caddy and Nix

I got tired of the services I used to check my current public IP address dropping off the face of the internet, so I made my own very minimal one using the Caddy web server, and deployed it with Nix


It’s often useful to be able to determine your “public” IP address1. Once upon a time this would have simply been the IP address assigned to your device’s network interface, but in 2022 (unless you’re at a hackercamp or possibly a University campus) you are almost certainly behind some form of NAT (Network Address Translation), allowing multiple devices on a network to share a single public IP address.

This means that, by itself, your device has no way of knowing what its public IP is. The easiest way to find out is to query a web server that will tell you the IP you are appearing as. There are rather a lot of these such services. Heck, you can even ask some search engines. But well, hosting your own things is just cooler. So…

What’s my IP, Caddy?

This would be a pretty simple custom web server. In fact, here is a bare minimum one, written in Go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import (
	"io"
	"net/http"
	"strings"
)

func handler(w http.ResponseWriter, r *http.Request) {
	io.WriteString(w, strings.Split(r.RemoteAddr, ":")[0])
}

func main() {
	http.ListenAndServe(":1234", http.HandlerFunc(handler))
}

But why do that when we could just use something off the shelf? We’ll be using Caddy, a modern web server that features zero configuration automatic HTTPS courtesy of Let’s Encrypt.

Here is the entire Caddy config (“Caddyfile”) to serve a HTTPS-secured “what’s my IP” site on the domain ip.example.com:

ip.example.com {
  respond "{remote_host}"
}

respond tells Caddy to just respond with a string, and {remote_host} is one of the config placeholders supported by the Caddyfile, which expands to the IP address of the requesting host. It’s that easy.

Bonus: deploying with Nix

You could install Caddy with a package manager and dump the above Caddyfile exerpt in /etc/Caddyfile on any old Linux distro, but instead we’re going to be using Nix. Nix (/NixOS) is a functional programming language, package “manager” and Linux distribution, all rolled into one project.

It’s quite a different approach to how you may have done similar things before, but once you get a bit familiar you can define how to build, configure and run any software, all using the same language. Here is the definition of our “whatsip” service in Nix:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# whatsip.nix

{
  config,
  lib,
  ...
}: let
  cfg = config.services.whatsip;
in {
  options.services.whatsip = {
    hostnames = lib.mkOption {
      type = lib.types.listOf lib.types.str;
      default = [];
    };
  };

  config = lib.mkIf (lib.length cfg.hostnames != 0) {
    services.caddy.enable = true;
    services.caddy.config = ''
      ${builtins.concatStringsSep ", " cfg.hostnames} {
        respond "{remote_host}"
      }
    '';
  };
}

This is not the place (nor am I qualified) to give an introduction to nix, but in brief we do two broad things here. Firstly, we define a single configuration option for a service that we’re calling “whatsip” - a list of hostnames. Secondly, we conditionally set a chunk of NixOS configuration, depending on whether the hostnames option we just defined is not empty.

This chunk enables the Caddy server, and sets its config - which is just the Caddyfile as a multiline string embedded in the Nix file. This allows us to use Nix’s string interpolation to take the list of hostnames from our config option, concatenate them together separated by commas and inject them into the Caddyfile.

This snippet forms a NixOS “module” - which, saved in a file, is then imported into a wider NixOS configuration for a whole system:

1
2
3
4
5
6
7
8
# configuration.nix
{ config, pkgs, ... }:
{
  # <snip>
  imports = [ ./whatsip.nix ];
  services.whatsip.hostnames = [ "ip.example.com" ];
  # <snip>
}

When this configuration is evaluated and a NixOS system built from it, Nix will have downloaded Caddy, configured systemd to run it and have built a Caddyfile with our hostnames and respond directive.

Double bonus: NixOS config merging

A not-immediately-obvious coda concerning NixOS modules: you could be forgiven for thinking that the above configuration would not play nicely with additional services.caddy.config occurences. Consider the above, but in the main configuration.nix for our system we already had a website configured using Caddy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# configuration.nix
{ config, pkgs, ... }:
{
  # <snip>
  imports = [ ./whatsip.nix ];

  # My really cool and interesting blog
  services.caddy.config = ''
    blog.example.com {
      root /var/www/myblog
      file_server
    }
  '';

  services.whatsip.hostnames = [ "ip.example.com" ];
  # snip
}

Here you might expect that services.caddy.config in configuration.nix might override the services.caddy.config we have in whatsip.nix, or vice versa. But, the NixOS modules system is smart and will automatically merge configurations, in this case concatenating our two Caddy config strings into one final Caddyfile. Which is exactly what we want - declarations of different services/websites can live in different places of your Nix configuration, neatly split up into separate logical modules. Such is the power of Nix ❄️.


  1. That is, the ip address that you appear as to the rest of the internet ↩︎