title:Tailscale Serve in a Docker Compose Sidecar
tags:["all", "containers", "docker", "selfhosting", "tailscale"]

Hi, and welcome back to what has become my Tailscale blog.

I have a few servers that I use for running multiple container workloads. My approach in the past had been to use Caddy webserver on the host to proxy the various containers. With this setup, each app would have its own DNS record, and Caddy would be configured to route traffic to the appropriate internal port based on that. For instance:

1cyberchef.runtimeterror.dev {
2 reverse_proxy localhost:8000
5ntfy.runtimeterror.dev, http://ntfy.runtimeterror.dev {
6 reverse_proxy localhost:8080
7 @httpget {
8 protocol http
9 method GET
10 path_regexp ^/([-_a-z0-9]{0,64}$|docs/|static/)
11 }
12 redir @httpget https://{host}{uri}
15uptime.runtimeterror.dev {
16 reverse_proxy localhost:3001
19miniflux.runtimeterror.dev {
20 reverse_proxy localhost:8080

and so on... You get the idea. This approach works well for services I want/need to be public, but it does require me to manage those DNS records and keep track of which app is on which port. That can be kind of tedious.

And I don't really need all of these services to be public. Not because they're particularly sensitive, but I just don't really have a reason to share my personal Miniflux or CyberChef instances with the world at large. Those would be great candidates to proxy with Tailscale Serve so they'd only be available on my tailnet. Of course, with that setup I'd then have to differentiate the services based on external port numbers since they'd all be served with the same hostname. That's not ideal either.

sudo tailscale serve --bg --https 8443 8180
Available within your tailnet:
|-- proxy
|-- proxy

It would be really great if I could directly attach each container to my tailnet and then access the apps with addresses like https://miniflux.tailnet-name.ts.net or https://cyber.tailnet-name.ts.net. Tailscale does have an official Docker image, and at first glance it seems like that would solve my needs pretty directly. Unfortunately, it looks like trying to leverage that container image directly would still require me to configure Tailscale Serve interactively.1.

Update: 2024-02-07

Tailscale just published a blog post which shares some details about how to configure Funnel and Serve within the official image. The short version is that the TS_SERVE_CONFIG variable should point to a serve-config.json file. The name of the file doesn't actually matter, but the contents do - and you can generate a config by running tailscale serve status -json on a functioning system... or just copy-pasta'ing this example I just made for the Cyberchef setup I describe later in this post:

2 "TCP": {
3 "443": {
4 "HTTPS": true
5 }
6 },
7 "Web": {
8 "cyber.tailnet-name.ts.net:443": {
9 "Handlers": {
10 "/": {
11 "Proxy": ""
12 }
13 }
14 }
15 }//, uncomment to enable funnel
16 // "AllowFunnel": {
17 // "cyber.tailnet-name.ts.net:443": true
18 // }

Replace the ports and protocols and hostnames and such, and you'll be good to go.

A compose config using this setup might look something like this:

2 tailscale:
3 image: tailscale/tailscale:latest
4 container_name: cyberchef-tailscale
5 restart: unless-stopped
6 environment:
8 TS_HOSTNAME: ${TS_HOSTNAME:-ts-docker}
10 TS_STATE_DIR: /var/lib/tailscale/
11 TS_SERVE_CONFIG: /config/serve-config.json
12 volumes:
13 - ./ts_data:/var/lib/tailscale/
14 - ./serve-config.json:/config/serve-config.json
16 cyberchef:
17 container_name: cyberchef
18 image: mpepping/cyberchef:latest
19 restart: unless-stopped
20 network_mode: service:tailscale

That's a bit cleaner than the workaround I'd put together, but you're totally welcome to keep on reading if you want to see how it compares.

And then I came across Louis-Philippe Asselin's post about how he set up Tailscale in Docker Compose. When he wrote his post, there was even less documentation on how to do this stuff, so he used a modified Tailscale docker image which loads a startup script to handle some of the configuration steps. His repo also includes a helpful docker-compose example of how to connect it together.

I quickly realized I could modify his startup script to take care of my Tailscale Serve need. So here's how I did it.

Docker Image

My image starts out basically the same as Louis-Philippe's, with just pulling in the official image and then adding the customized script:

1FROM tailscale/tailscale:v1.56.1
2COPY start.sh /usr/bin/start.sh
3RUN chmod +x /usr/bin/start.sh
4CMD ["/usr/bin/start.sh"]

My start.sh script has a few tweaks for brevity/clarity, and also adds a block for conditionally enabling a basic Tailscale Serve (or Funnel) configuration:

2trap 'kill -TERM $PID' TERM INT
3echo "Starting Tailscale daemon"
4tailscaled --tun=userspace-networking --statedir="${TS_STATE_DIR}" ${TS_TAILSCALED_EXTRA_ARGS} &
6until tailscale up --authkey="${TS_AUTHKEY}" --hostname="${TS_HOSTNAME}" ${TS_EXTRA_ARGS}; do
7 sleep 0.1
9tailscale status
+if [ -n "${TS_SERVE_PORT}" ]; then
+ if [ -n "${TS_FUNNEL}" ]; then
+ if ! tailscale funnel status | grep -q -A1 '(Funnel on)' | grep -q "${TS_SERVE_PORT}"; then
+ tailscale funnel --bg "${TS_SERVE_PORT}"
+ fi
+ else
+ if ! tailscale serve status | grep -q "${TS_SERVE_PORT}"; then
+ tailscale serve --bg "${TS_SERVE_PORT}"
+ fi
+ fi
21wait ${PID}

This script starts the tailscaled daemon in userspace mode, and it tells the daemon to store its state in a user-defined location. It then uses a supplied pre-auth key to bring up the new Tailscale node and set the hostname.

If both TS_SERVE_PORT and TS_FUNNEL are set, the script will publicly proxy the designated port with Tailscale Funnel. If only TS_SERVE_PORT is set, it will just proxy it internal to the tailnet with Tailscale Serve.2

I'm using this git repo to track my work on this, and it automatically builds my tailscale-docker image. So now I can can simply reference ghcr.io/jbowdre/tailscale-docker in my Docker configurations.

On that note...

Compose Configuration

There's also a sample docker-compose.yml in the repo to show how to use the image:

2 tailscale:
3 image: ghcr.io/jbowdre/tailscale-docker:latest
4 restart: unless-stopped
5 container_name: tailscale
6 environment:
7 TS_AUTHKEY: ${TS_AUTHKEY:?err} # from https://login.tailscale.com/admin/settings/authkeys
8 TS_HOSTNAME: ${TS_HOSTNAME:-ts-docker} # optional hostname to use for this node
9 TS_STATE_DIR: "/var/lib/tailscale/" # store ts state in a local volume
10 TS_TAILSCALED_EXTRA_ARGS: ${TS_TAILSCALED_EXTRA_ARGS:-} # optional extra args to pass to tailscaled
11 TS_EXTRA_ARGS: ${TS_EXTRA_ARGS:-} # optional extra flags to pass to tailscale up
12 TS_SERVE_PORT: ${TS_SERVE_PORT:-} # optional port to proxy with tailscale serve (ex: '80')
13 TS_FUNNEL: ${TS_FUNNEL:-} # if set, serve publicly with tailscale funnel
14 volumes:
15 - ./ts_data:/var/lib/tailscale/ # the mount point should match TS_STATE_DIR
16 myservice:
17 image: nginxdemos/hello
18 restart: unless-stopped
19 network_mode: "service:tailscale" # use the tailscale network service's network

You'll note that most of those environment variables aren't actually defined in this YAML. Instead, they'll be inherited from the environment used for spawning the containers. This provides a few benefits. First, it lets the tailscale service definition block function as a template to allow copying it into other Compose files without having to modify. Second, it avoids holding sensitive data in the YAML itself. And third, it allows us to set default values for undefined variables (if TS_HOSTNAME is empty it will be automatically replaced with ts-docker) or throw an error if a required value isn't set (an empty TS_AUTHKEY will throw an error and abort).

You can create the required variables by exporting them at the command line (export TS_HOSTNAME=ts-docker) - but that runs the risk of having sensitive values like an authkey stored in your shell history. It's not a great habit.

Perhaps a better approach is to set the variables in a .env file stored alongside the docker-compose.yaml but with stricter permissions. This file can be owned and only readable by root (or the defined Docker user), while the Compose file can be owned by your own user or the docker group.

Here's how the .env for this setup might look:

Variable NameExampleDescription
TS_AUTHKEYtskey-auth-somestring-somelongerstringused for unattended auth of the new node, get one here
TS_HOSTNAMEtsdemooptional Tailscale hostname for the new node3
TS_STATE_DIR/var/lib/tailscale/required directory for storing Tailscale state, this should be mounted to the container for persistence
TS_TAILSCALED_EXTRA_ARGS--verbose=14optional additional flags for tailscaled
TS_EXTRA_ARGS--ssh5optional additional flags for tailscale up
TS_SERVE_PORT8080optional application port to expose with Tailscale Serve
TS_FUNNEL1if set (to anything), will proxy TS_SERVE_PORT publicly with Tailscale Funnel

A few implementation notes:

  • If you want to use Funnel with this configuration, it might be a good idea to associate the Funnel ACL policy with a tag (like tag:funnel), as I discussed a bit here. And then when you create the pre-auth key, you can set it to automatically apply the tag so it can enable Funnel.
  • It's very important that the path designated by TS_STATE_DIR is a volume mounted into the container. Otherwise, the container will lose its Tailscale configuration when it stops. That could be inconvenient.
  • Linking network_mode on the application container back to the service:tailscale definition is the magic that lets the sidecar proxy traffic for the app. This way the two containers effectively share the same network interface, allowing them to share the same ports. So port 8080 on the app container is available on the tailscale container, and that enables tailscale serve --bg 8080 to work.

Usage Examples

To tie this all together, I'm going to quickly run through the steps I took to create and publish two container-based services without having to do any interactive configuration.


I'll start with my CyberChef instance.

CyberChef is a simple, intuitive web app for carrying out all manner of "cyber" operations within a web browser. These operations include simple encoding like XOR and Base64, more complex encryption like AES, DES and Blowfish, creating binary and hexdumps, compression and decompression of data, calculating hashes and checksums, IPv6 and X.509 parsing, changing character encodings, and much more.

This will be served publicly with Funnel so that my friends can use this instance if they need it.

I'll need a pre-auth key so that the Tailscale container can authenticate to my Tailnet. I can get that by going to the Tailscale Admin Portal and generating a new auth key. I gave it a description, ticked the option to pre-approve whatever device authenticates with this key (since I have Device Approval enabled on my tailnet). I also used the option to auto-apply the tag:internal tag I used for grouping my on-prem systems as well as the tag:funnel tag I use for approving Funnel devices in the ACL.

authkey creation

That gives me a new single-use authkey:

new authkey

I'll use that new key as well as the knowledge that CyberChef is served by default on port 8000 to create an appropriate .env file:


And I can add the corresponding docker-compose.yml to go with it:

2 tailscale:
3 image: ghcr.io/jbowdre/tailscale-docker:latest
4 restart: unless-stopped
5 container_name: cyberchef-tailscale
6 environment:
8 TS_HOSTNAME: ${TS_HOSTNAME:-ts-docker}
9 TS_STATE_DIR: "/var/lib/tailscale/"
14 volumes:
15 - ./ts_data:/var/lib/tailscale/
16 cyberchef:
17 container_name: cyberchef
18 image: mpepping/cyberchef:latest
19 restart: unless-stopped
20 network_mode: service:tailscale

I can just bring it online like so:

docker compose up -d
[+] Running 3/3
Network cyberchef_default Created
Container cyberchef-tailscale Started
Container cyberchef Started

I can review the logs for the tailscale service to confirm that the Funnel configuration was applied:

docker compose logs tailscale
cyberchef-tailscale | # Health check:
cyberchef-tailscale | # - not connected to home DERP region 12
cyberchef-tailscale | # - Some peers are advertising routes but --accept-routes is false
cyberchef-tailscale | 2023/12/30 17:44:48 serve: creating a new proxy handler for
cyberchef-tailscale | 2023/12/30 17:44:48 Hostinfo.WireIngress changed to true
cyberchef-tailscale | Available on the internet:
cyberchef-tailscale |
cyberchef-tailscale | https://cyber.tailnet-name.ts.net/
cyberchef-tailscale | |-- proxy
cyberchef-tailscale |
cyberchef-tailscale | Funnel started and running in the background.
cyberchef-tailscale | To disable the proxy, run: tailscale funnel --https=443 off

And after ~10 minutes or so (it sometimes takes a bit longer for the DNS and SSL to start working outside the tailnet), I'll be able to hit the instance at https://cyber.tailnet-name.ts.net from anywhere on the web.



I've lately been playing quite a bit with my omg.lol address and associated services, and that's inspired me to revisit the world of curating RSS feeds instead of relying on algorithms to keep me informed. Through that experience, I recently found Miniflux, a "Minimalist and opinionated feed reader". It's written in Go, is fast and lightweight, and works really well as a PWA installed on mobile devices, too.

It will be great for keeping track of my feeds, but I need to expose this service publicly. So I'll serve it up inside my tailnet with Tailscale Serve.

Here's the .env that I'll use:


Funnel will not be configured for this since TS_FUNNEL was not defined.

I adapted the example docker-compose.yml from Miniflux to add in my Tailscale bits:

2 tailscale:
3 image: ghcr.io/jbowdre/tailscale-docker:latest
4 restart: unless-stopped
5 container_name: miniflux-tailscale
6 environment:
8 TS_HOSTNAME: ${TS_HOSTNAME:-ts-docker}
9 TS_STATE_DIR: "/var/lib/tailscale/"
14 volumes:
15 - ./ts_data:/var/lib/tailscale/
16 miniflux:
17 image: miniflux/miniflux:latest
18 restart: unless-stopped
19 container_name: miniflux
20 depends_on:
21 db:
22 condition: service_healthy
23 environment:
24 - DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@db/miniflux?sslmode=disable
29 network_mode: "service:tailscale"
30 db:
31 image: postgres:15
32 restart: unless-stopped
33 container_name: miniflux-db
34 environment:
37 volumes:
38 - ./mf_data:/var/lib/postgresql/data
39 healthcheck:
40 test: ["CMD", "pg_isready", "-U", "${DB_USER}"]
41 interval: 10s
42 start_period: 30s

I can bring it up with:

docker compose up -d
[+] Running 4/4
Network miniflux_default Created
Container miniflux-db Started
Container miniflux-tailscale Started
Container miniflux Created

And I can hit it at https://miniflux.tailnet-name.ts.net from within my tailnet:


Nice, right? Now to just convert all of my other containerized apps that don't really need to be public. Fortunately that shouldn't take too long since I've got this nice, portable, repeatable Docker Compose setup I can use.

Maybe I'll write about something other than Tailscale soon. Stay tuned!

  1. While not documented for the image itself, the containerboot binary seems like it should accept a TS_SERVE_CONFIG argument to designate the file path of the ipn.ServeConfig... but I couldn't find any information on how to actually configure that. ↩︎

  2. If neither variable is set, the script just brings up Tailscale like normal... in which case you might as well just use the official image. ↩︎

  3. This hostname will determine the fully-qualified domain name where the resource will be served: https://[hostname].[tailnet-name].ts.net. So you'll want to make sure it's a good one for what you're trying to do. ↩︎

  4. Passing the --verbose flag to tailscaled increases the logging verbosity, which can be helpful if you need to troubleshoot. ↩︎

  5. The --ssh flag to tailscale up will enable Tailscale SSH and (ACLs permitting) allow you to easily SSH directly into the Tailscale container without having to talk to the Docker host and spawn a shell from there. ↩︎

Celebrate this post: