Utilize Tailscale Serve and Funnel to Expose Your Dockerized Web Application on Intranet or Public Internet

Tailscale isn’t only useful for home lab setups. Given the correct configuration and understanding, web developers can use it as a proper replacement for ngrok.

Are you a freelance developer looking for an efficient way to share your work with clients without deploying to a live server? Or perhaps you’re exploring better alternatives to ngrok for exposing your local services? Look no further! In this comprehensive guide, we’ll walk you through using Tailscale Serve and Funnel to deploy a Dockerised WordPress application to your private Tailscale network (AKA a tailnet) or the public internet.

A preview of what you’ll end up with

That’s pretty nifty. And yes, you can configure it to be accessible from exclusively private or public networks.

Before we get started

Before we dive into the setup, let’s quickly highlight why Tailscale is an excellent choice:

  1. Free to use, unlike ngrok’s paid plans for extended features. You can see for yourself here.
  2. Perfect for applications requiring static redirect URLs (e.g., OAuth2). Yes, you choose your short hostname (e.g. wordpress).
  3. It works with any IP protocol (TCP, UDP, etc.), while ngrok only supports TCP.
  4. Automatic LetsEncrypt certificate management. This is probably the best part.

Some assumptions

  • You’re comfortable with bash commands.
  • You know what Tailscale is and how it works. It’s not a requirement, but it would be good to install it on your host machine to test how private networking works when Funnel is disabled.
  • You have Docker installed and have previous experience running orchestrated containers with docker compose.
  • You have some understanding of how TLS/SSL certificates work and the benefits they provide.

Let’s get cracking

Create a new directory for your project and navigate to it:

mkdir wordpress-tailscale && cd wordpress-tailscale

Create the following files in your project directory:

docker-compose.yml

services:
  db:
    image: mysql:8
    container_name: ${COMPOSE_PROJECT_NAME}_db
    volumes:
      - db_data:/var/lib/mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: somewordpress
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress

  wordpress:
    depends_on:
      - db
    image: wordpress:6.5.5-php8.1
    container_name: ${COMPOSE_PROJECT_NAME}_wordpress
    volumes:
      - wordpress_data:/var/www/html
    restart: always
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress

  tailscale:
    image: tailscale/tailscale:latest
    container_name: ${COMPOSE_PROJECT_NAME}_tailscale
    volumes:
      - tailscale_data:/var/lib/tailscale
      - /dev/net/tun:/dev/net/tun
      - ${PWD}/config/tailscale/tailscale-${TAILSCALE_PRIVACY:-private}.json:/config/tailscale.json:ro
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    environment:
      - TS_AUTHKEY=${TS_AUTHKEY}
      - TS_HOSTNAME=${TS_HOSTNAME}
      - TS_USERSPACE=${TS_USERSPACE:-false}
      - TS_SERVE_CONFIG=${TS_SERVE_CONFIG:-/config/tailscale.json}
      - TS_PRIVACY=${TS_PRIVACY:-private}

  nginx:
    image: nginx:1.27.0
    container_name: ${COMPOSE_PROJECT_NAME}_nginx
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - wordpress
      - tailscale
    restart: always

volumes:
  db_data:
  wordpress_data:
  tailscale_data:

.env

COMPOSE_PROJECT_NAME=wordpress_on_tailscale

TS_AUTHKEY="<get-this-from-tailscale>"
TS_HOSTNAME="wordpress"
TS_PRIVACY=private

Likewise, with anything on the .env file, you can change wordpress to something different, and it will be the hostname for the lifetime that the Dockerised application is up, e.g. birdpress.brown-herring.ts.net.

Follow this official guide to generate an ephemeral auth key. I stress ephemeral because it’s very likely that you’ll be turning the container off and on multiple times, in which case you want Tailscale to clean up after itself and de-register the hostname on the console when the local application is no longer online.

It’s easy to generate an auth key.

nginx.conf

events {
    worker_connections 1024;
}

http {
    server {
        listen 8000;

        # The specific value upgrade-insecure-requests instructs the browser to automatically upgrade all navigational requests from HTTP to HTTPS.
        # This means that if a webpage served over HTTPS includes links or references to resources loaded over HTTP, the browser will attempt to load these resources over HTTPS instead.
        # Wordpress has a lot of resources that are loaded over HTTP, so this is a good way to ensure that all resources are loaded over HTTPS.
        add_header Content-Security-Policy "upgrade-insecure-requests";

        location / {
            proxy_pass http://wordpress:80;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

config/tailscale/tailscale-private.json

{
  "TCP": {
    "443": {
      "HTTPS": true
    }
  },
  "Web": {
    "${TS_CERT_DOMAIN}:443": {
      "Handlers": {
        "/": {
          "Proxy": "nginx:8000"
        }
      }
    }
  },
  "AllowFunnel": {
    "${TS_CERT_DOMAIN}:443": false
  }
}

config/tailscale/tailscale-public.json

{
  "TCP": {
    "443": {
      "HTTPS": true
    }
  },
  "Web": {
    "${TS_CERT_DOMAIN}:443": {
      "Handlers": {
        "/": {
          "Proxy": "nginx:8000"
        }
      }
    }
  },
  "AllowFunnel": {
    "${TS_CERT_DOMAIN}:443": true
  }
}

Notice that the difference between both .json files is the AllowFunnel directive. Having a value of false means wordpress.brown-herring.ts.net will be accessible only to your tailnet, which is perfect for team-based development. The firewall bit is handled for you!

You should now have a directory structure like this:

You can see that it only takes a few files to get us off the ground

Publishing the web application

Let’s not waste any time. I know you’re just as excited as I am.

Start the containers:

docker compose up -d

I ran that with:Docker version 27.0.2, build 912c1dd

Wait a few moments while Docker pulls the necessary images and starts the containers. You can monitor the logs with:

docker compose logs -f

Once the containers are up and running, your WordPress site will be accessible through your Tailscale network (like the screenshot shown at the start of this guide), and you can see the request logs come through:

The logs should update whenever you visit the application.

You can even continue setting up the Wordpress application if you like.

Too easy

Configuring Tailscale Serve and Funnel

Tailscale Serve allows you to expose your service to your private Tailscale network, while Funnel enables public access. The configuration for both is defined in the tailscale.json file, as previously mentioned.

Private Network (Serve)

By default, the setup uses the tailscale-private.json configuration, which only allows access through your Tailscale network. This is why asked you to log your host machine in earlier to join the tailnet. If you aren’t logged in, you won’t be able to see the website.

This also means that if your iPad, iPhone, or any other device are connected to the tailnet, they can see the website.

Public Access (Funnel)

To enable public access, which is what most people are after:

  1. Update your .env file:
TS_PRIVACY=public

2. Restart your containers:

docker compose down --remove-orphans --volumes
docker compose up -d

3. Disconnect from your tailnet or use your grandma’s phone (assuming she’s not on the tailnet) and visit the website to verify that it is publicly available.

You can also check the configuration of your Tailscale service with:

docker compose exec tailscale tailscale serve status --json

Thank you for reading

Using Tailscale Serve and Funnel with Docker provides a powerful and flexible way to deploy and share your WordPress applications. Whether you need private access for your team or public access for clients, this setup offers a secure and efficient solution. As Tailscale continues to evolve, we can expect even more features and improvements in the future. Give it a try and experience the benefits of Tailscale over traditional solutions like ngrok.