How to set up a private web site with Tailscale in 5 minutes

Published on 2022-08-12

I have a personal collection of HTML documents that I want available to me when I’m on the go, but that I don’t want or need to host on public internet.

To do that, I need a web server that I can access, but you can’t.

Tailscale makes it really easy to set up a network of devices connected via Wireguard tunnels. In other words, it gives you a VPN that doesn’t suck. Not only does it not suck, it works really well.

Here’s the basic recipe for setting up a private website:

The whole process takes less than 10 minutes - 5 if you’re quick with it.

I’ll be running this on a FreeBSD host in GCP. The commands may be different on your machine, but the general principles and techniques are the same.

Add a host to your Tailnet

Note: I like to upgrade any existing packages before installing new packages.

Once you have a Tailscale account, install Tailscale on your host and add it to your Tailnet:

# pkg install -y tailscale
# service tailscaled enable
# service tailscaled start
# tailscale up

This will print an auth URL to the terminal. Open it in your browser, go through the auth process, and your host will be on the Tailnet.

Serve a directory with nginx

Install nginx, and point it at a directory with your site content:

$ mkdir ~/www
$ echo "<h1>hello world</h1>" > ~/www/index.html

# pkg install -y nginx
# service nginx enable
# sed -i .bak 's#root.*/usr/local/www/nginx;#root /home/patmaddox/www;#' /usr/local/etc/nginx/nginx.conf
# service nginx start

At this point, you can access your site via any accessible IPs (the tailscale IP, localhost, and the host’s network).

Let’s configure the host to only accept HTTP traffic on the Tailscale interface, using pf.

Lock down the server with a firewall

Note: Make sure you set the ext_if variable to match your interface name, otherwise you will lock yourself out!

Configure the firewall:

# sh # switch to sh to get real heredoc support

# cat > /etc/pf.conf << 'EOF'

set skip on lo
scrub in

block in
pass out

pass in proto tcp to port { 22 }
pass in inet proto icmp icmp-type { echoreq }
pass in on $ts_if proto tcp to port { 80 }

# service pf enable
# service pf start

At this point, your connection will hang. SSH to your host again.

You will now be able to access the web server via the Tailscale IP, and the loopback address, but not any other IPs.

Listen only on the Tailscale IP

Although pf prevents outsiders from accessing port 80, nginx is still listening on all interfaces:

# sockstat -l | grep nginx
www      nginx      2765  6  tcp4   *:80    *:*
root     nginx      2764  6  tcp4   *:80    *:*

If I mess up the firewall configuration at some point, my site may become visible on other interfaces.

Configure nginx to listen only on the Tailscale IP:

# sed -i .bak "s/listen.*80;/listen `tailscale ip -4`:80;/" /usr/local/etc/nginx/nginx.conf
# service nginx reload
# sockstat -l | grep nginx
www      nginx      2765  6  tcp4    *:*
root     nginx      2764  6  tcp4    *:*

Now the only way to access this web server is via another host on my Tailnet. That’s pretty locked down!

We can take it a step further by serving HTTPS instead of HTTP, using Tailscale’s built-in Let’s Encrypt certificates.

Configure HTTPS with Tailscale certs

Generate certificates:

# mkdir /usr/local/etc/nginx/certs
# tailscale cert \
--cert-file /usr/local/etc/nginx/certs/`hostname`.crt \
--key-file /usr/local/etc/nginx/certs/`hostname`.key \

Now edit /usr/local/etc/nginx/nginx.conf to point to the cert files, and change the listener for HTTPS:

listen ssl;
server_name  <full-tailscale-hostname>;
ssl_certificate /usr/local/etc/nginx/certs/<hostname>.crt;
ssl_certificate_key /usr/local/etc/nginx/certs/<hostname>.key;

Open port 443 on the firewall, and reload nginx and pf

# sed -i .bak 's/80/443/' /etc/pf.conf
# service nginx reload
# service pf reload

There you have it: a private website, accessible only via Wireguard-encrypted tunnel, with host verification.

Questions? Comments? Get in touch!