{"slug":"contain-your-excitement-a-deep-dive-into-using-tailscale-with-docker","title":"Contain your excitement: A deep dive into using Tailscale with Docker","tags":["tailscale","containers","logging"],"agent_summary":"Creating small, trusted networks with your friends, family and coworkers is central to our mission here at Tailscale. In this article we're going to dive deep i","trigger_phrases":[],"runnable":false,"markdown":"\r\n[Blog](https://tailscale.com/blog) \\|insightsFebruary 07, 2024\r\n\r\n# Contain your excitement: A deep dive into using Tailscale with Docker\r\n\r\n![Orange shapes on a dark orange background](https://cdn.sanity.io/images/w77i7m8x/production/ace904acf6a3886bf19faf2f56bf223bfec1b995-600x315.svg?w=1200&q=75&fit=clip&auto=format)\r\n\r\nCreating small, trusted networks with your friends, family and coworkers is central to our mission here at Tailscale. In this article we're going to dive deep into using Docker to do just that, with containers.\r\n\r\nWhy might you want to consider putting Tailscale in a container? By design, containers are isolated execution environments. Access to resources both inside and outside of these container environments must be explicitly granted and managed.\r\n\r\nBy putting containers directly onto your tailnet (our term for a Tailscale network), you're able to connect these isolated things together no matter the networking topology that stands in your way.\r\n\r\nEach container becomes a fully-fledged node on your tailnet, just like any other. This means no reverse proxies. No complex firewall rules or port forwarding. And no matter how bad your NAT situation, Tailscale will traverse it.\r\n\r\nA deep dive into using Tailscale with Docker - YouTube\r\n\r\nTap to unmute\r\n\r\n[A deep dive into using Tailscale with Docker](https://www.youtube.com/watch?v=tqvvZhGrciQ) [Tailscale](https://www.youtube.com/channel/UCcdv38QxPjSMqbt5ffLhJLA)\r\n\r\n![thumbnail-image](https://yt3.ggpht.com/K3B5Hdi7j4zy2y53nlQCQb9J0qHNuSXhD2hWysrwaiDvv19tPr5RY04QuQfFOvb9rDq9cugGjZU=s68-c-k-c0x00ffffff-no-rj)\r\n\r\nTailscale68.5K subscribers\r\n\r\n[Watch on](https://www.youtube.com/watch?v=tqvvZhGrciQ)\r\n\r\nThis post is also available as a YouTube video, and copies of the files used throughout are available in [this GitHub repo](https://github.com/tailscale-dev/docker-guide-code-examples). To make the most of this article some familiarity with Docker, Docker Compose, and general networking concepts is advised.\r\n\r\nToday we're going to cover adding a container directly to your tailnet with auth keys, and OAuth secrets (and the difference between them), plus how to proxy a web service through Tailscale both locally via Serve and over the public internet via Funnel.\r\n\r\n## Adding a container to your tailnet\r\n\r\nTailscale offers an official [Docker container](https://tailscale.com/kb/1282/docker) which provides several parameters we'll need to configure via environment variables. A full list of those variables is provided in our [documentation](https://tailscale.com/kb/1282/docker#parameters).\r\n\r\nThere are two primary methods to add a container to your tailnet. Option 1 is to use an auth key, and option 2 is using OAuth secrets—we'll cover both here as they can be useful in different scenarios.\r\n\r\n### Should I use Auth Keys or OAuth secrets?\r\n\r\nCan we just agree that this is a classic \"it depends\" situation? _\\[editor: no.\\]_ Ok, fine.\r\n\r\nBefore we dig into how to use them, let's figure out which authentication method is right for you. Both methods support a lot of the same things and both will work for many of you. However, there are subtle differences between these two authentication methods which may make or break your use case.\r\n\r\n| Auth keys | OAuth clients |\r\n| --- | --- |\r\n| ✅ Grant full API access | ✅ Limits API access via scopes e.g. `dns:read` |\r\n| ✅ Maximum lifespan of 90 days | ✅ Never expires |\r\n| ✅ Doesn't require tags | ✅ Requires tags for created nodes |\r\n| ✅ Authenticates the machine as the user who generated the key | ✅ Authenticates nodes via `tagOwner` tag |\r\n\r\nIn order to better understand some of the nuance here, let's examine node ownership and tags. When a node is authenticated to your tailnet via `tailscale up`, the user who logged into the admin console and clicked the generate button owns that node. That ownership manifests as a tag, which is why we refer to them in ACLs as `tagOwners`. Each node on a tailnet must have an owner, whether it's a user or a tag.\r\n\r\nWhen you use an auth key, the node is added to your tailnet as the user who generated the key. With an OAuth client the node is owned by the tag assigned at secret creation time.\r\n\r\nAnother crucial difference is token / key expiry. On the face of it, it _appears_ that an auth key will be useless after the maximum 90 day expiry window. However, when an auth key expires it doesn't automatically mean any machine which used it to authenticate to your tailnet is suddenly expired as well—it simply means you can't use that key to add any _new_ nodes, existing ones continue to function until their [node keys expire](https://tailscale.com/kb/1028/key-expiry) (default is 180 days). Nodes added with an OAuth client never expire.\r\n\r\nNow we know some of the nuances involved, let's dig into added a container to your tailnet using these methods.\r\n\r\n### Using Auth Keys\r\n\r\nTo use an auth key we need to generate one using the admin console. Navigate to [https://login.tailscale.com/admin/settings/keys](https://login.tailscale.com/admin/settings/keys) and select **Generate auth key**. The most up to date information on generating auth keys and the available parameters can be found [in this kb article](https://tailscale.com/kb/1085/auth-keys#about-auth-keys). The values you select for your auth key will be highly specific to your environment, but for our purposes here let's just use the following to get you started:\r\n\r\n- Description: `docker-testing`\r\n- Reusable: `yes`\r\n- Expiration: `7 days`\r\n- Ephemeral: `No`\r\n- Tags: `tag:container`\r\n\r\nNote that in order to use a tag, it must first be defined in your [Access control policy](https://tailscale.com/kb/1018/acls) like so:\r\n\r\n```\r\n\"tagOwners\": {\r\n\t\"tag:container\": [\"autogroup:admin\"],\r\n},\r\n```\r\n\r\nIf you're feeling adventurous or programmatic is your middle name, we also provide a utility named `get-authkey` to [automate this process](https://tailscale.com/kb/1215/oauth-clients#get-authkey-utility).\r\n\r\nOnce you have an auth key (do not share or lose this, treat it like a password), we want to plug it into our container using `TS_AUTHKEY=tskey-auth-123abc...` as an environment variable (see below).\r\n\r\nIt's vitally important that the container is able to persist the Tailscale session state across reboots and recreation events. To do this we need to map `/var/lib/tailscale` to a volume (see below for an example), and also explicitly configure the path the container will use to store state using the environment variable `TS_STATE_DIR=/var/lib/tailscale`.\r\n\r\nHere's a full example docker compose YAML file showing a minimum viable configuration to get a simple nginx based web server onto your tailnet. When `docker compose up` is run against this file a node will be added to your tailnet with the name `webserver1`. All the usual Tailscale goodies like MagicDNS, subnet routing, and access controls via ACLs, are available inside the context of this container. It's just another node on your tailnet, after all.\r\n\r\n```yaml\r\n# compose-authkey.yaml\r\n---\r\nversion: \"3.7\"\r\nservices:\r\n  ts-webserver1:\r\n    image: tailscale/tailscale:latest\r\n    hostname: webserver1\r\n    environment:\r\n      - TS_AUTHKEY=tskey-auth-k7TsXC6CNTRL-D9notarealAuthKey2NFQQcUvtP9WJ\r\n      - TS_STATE_DIR=/var/lib/tailscale\r\n    volumes:\r\n      - tailscale-data-webserver1:/var/lib/tailscale\r\n\t\tdevices:\r\n      - /dev/net/tun:/dev/net/tun\r\n    cap_add:\r\n      - net_admin\r\n      - sys_module\r\n    restart: unless-stopped\r\n  webserver1:\r\n      image: nginx\r\n      network_mode: service:ts-webserver1\r\n      depends_on:\r\n        - ts-webserver1\r\nvolumes:\r\n  tailscale-data-webserver1:\r\n    driver: local\r\n```\r\n\r\nWe've deployed a Tailscale docker container alongside an nginx web server container. The `network_mode` setting on the nginx container routes all traffic for that container via the Tailscale container—this is often referred to as a \"sidecar\" container. In this fashion we can effectively directly deploy multiple individual services to our tailnet with unique names matching the service. This is a deployment pattern we'll come back to later on in this post, so don't worry if the details don't quite click yet.\r\n\r\nOne last point on auth keys. There is a common misconception that auth keys aren't useful for long running workloads due to the fact that they have a maximum life span of 90 days. However, if a node comes up tagged then key expiry is automatically disabled. Furthermore, so long as the container is able to persist state, removing the auth key completely (or it expiring) has no impact once the node has joined your tailnet. What will be affected after the auth key expires is the ability to join _new_ nodes to your tailnet without rotating the key.\r\n\r\n### Using OAuth\r\n\r\nOAuth clients provide a framework for delegated and scoped access to the [Tailscale API](https://tailscale.com/kb/1101/api). In other words, unlike an auth key which is fully scoped (i.e., if you have this key you can do anything), OAuth clients allow fine-grained control on the access granted to client. You can get a better idea of the scopes offered in the [documentation](https://tailscale.com/kb/1215/oauth-clients#scopes) but we're going to focus on the `Auth Keys: Write` scope. This limits us to auth key–related actions and we cannot, for example, modify ACLs or DNS on clients authenticated with this token. If the word auditor is in regular use in your vocabulary, then OAuth should be on your radar.\r\n\r\nBut how does one go about using an OAuth client token with Docker? Instead of using `TS_AUTHKEY=tskey-auth-blah` we replace that value with our OAuth client secret, and define another environment variable stating our desired tag like `TS_EXTRA_ARGS=--advertise-tags=tag:container`. Behind the scenes, the Tailscale container will execute `tailscale up`, recognize this is an OAuth client secret and use that mechanism to automatically generate an auth key for you. The outcome is a container on your tailnet all the same.\r\n\r\nTo generate an OAuth client token:\r\n\r\n1. visit [https://login.tailscale.com/admin/settings/oauth](https://login.tailscale.com/admin/settings/oauth)\r\n2. click **Generate OAuth client**\r\n3. fill out the description\r\n4. select `Auth Keys: Write`\r\n   - (note `Auth Keys: Read` will be automatically selected too)\r\n5. select an appropriate tag\r\n   - in our example we created the tag `container`\r\n   - as in the auth key section you must have already defined an ACL tag before this step in your ACLs\r\n6. then it's time to **Generate Client**\r\n\r\nThe `Client ID` is not important for our purposes, nor is it particularly sensitive. The `Client secret` however, should be treated like a password - it is a secret after all. This key is only shown once and cannot be rotated. To rotate a OAuth client secret you must revoke and recreate a new one.\r\n\r\nHere's the same example from above but configured to use OAuth instead of auth keys:\r\n\r\n```\r\n# compose-oauth.yaml\r\n---\r\nversion: \"3.7\"\r\nservices:\r\n  ts-webserver2:\r\n    image: tailscale/tailscale:latest\r\n    hostname: webserver2\r\n    environment:\r\n      - TS_AUTHKEY=tskey-client-kXGGbs6CNTRL-wXGXnotarealsecret1U3aeeaj\r\n      - TS_EXTRA_ARGS=--advertise-tags=tag:container\r\n      - TS_STATE_DIR=/var/lib/tailscale\r\n    volumes:\r\n      - tailscale-data-webserver2:/var/lib/tailscale\r\n\t\tdevices:\r\n      - /dev/net/tun:/dev/net/tun\r\n    cap_add:\r\n      - net_admin\r\n      - sys_module\r\n    restart: unless-stopped\r\n  webserver2:\r\n      image: nginx\r\n      network_mode: service:ts-webserver2\r\n      depends_on:\r\n        - ts-webserver2\r\nvolumes:\r\n  tailscale-data-webserver2:\r\n    driver: local\r\n```\r\n\r\nAs was the case in our previous auth key example, this compose snippet will create a container and join it to your tailnet automatically with the hostname `webserver2`. We're also creating that same nginx web server, which proxies all its traffic via the Tailscale container thanks to the `network_mode: service:ts-webserver2` parameter.\r\n\r\nThose of you paying attention might notice that by default an OAuth client authenticated node is marked as `ephemeral`. In other words as soon as you stop the container (or very soon after), it is removed from your tailnet altogether. This might be really handy if it's a temporary thing like a CI build but for other services we want more permanence. To this end, append the argument `?ephemeral=false` to `TS_AUTHKEY` in the form `TS_AUTHKEY=tskey-client-kXGGbs6CNTRL-wXGXnotarealsecret1U3aeeaj?ephemeral=false`. Our documentation includes information about [the other arguments available](https://tailscale.com/kb/1215/oauth-clients#registering-new-nodes-using-oauth-credentials).\r\n\r\n## Service linking\r\n\r\nNow we know how to join a container to your tailnet, let's take a look at the mechanics of what linking a sidecar container to your service is actually doing. Documentation from Docker on what exactly `network_mode: service: <service-name>` does is a little sparse—see its [section on container networks](https://docs.docker.com/network/#container-networks) and [`network_mode` in particular](https://docs.docker.com/compose/compose-file/compose-file-v3/#network_mode). However, we can examine what's going on for ourselves easily enough.\r\n\r\nHere is an example docker compose file which instantiates a web service container, and a Tailscale container acting as a sidecar proxy to the web service.\r\n\r\n```\r\n---\r\nversion: \"3.7\"\r\nservices:\r\n  ts-nginx-test:\r\n    image: tailscale/tailscale:latest\r\n    container_name: ts-nginx-test\r\n    hostname: nginx-test\r\n    environment:\r\n      - TS_AUTHKEY=tskey-auth-kvt6L96CNTRL-SnotarealkeyTUxNkuuYb14d\r\n      - TS_STATE_DIR=/var/lib/tailscale\r\n    volumes:\r\n      - ${PWD}/ts-nginx-test/state:/var/lib/tailscale\r\n\t\tdevices:\r\n      - /dev/net/tun:/dev/net/tun\r\n    cap_add:\r\n      - net_admin\r\n      - sys_module\r\n    restart: unless-stopped\r\n  nginx-test:\r\n    image: nginx\r\n    network_mode: service:ts-nginx-test  # <-- this is where the magic happens\r\n\r\nvolumes:\r\n  ts-alpine:\r\n    driver: local\r\n```\r\n\r\nThe end result is that we can visit `http://nginx-test` in our browser and reach the nginx service hello world web page. If we execute `curl http://nginx-test` from another node on our tailnet, we receive a response showing that traffic is flowing through the sidecar container to the underlying nginx container. There are no ports mapped to the host from either container, nor any other method of data ingress or egress except via Tailscale.\r\n\r\n```\r\n# curl http://nginx-test\r\n<html>\r\n<h1>Welcome to nginx!</h1>\r\n<p>If you see this page, the nginx web server is successfully installed and\r\nworking. Further configuration is required.</p>\r\n</html>\r\n```\r\n\r\nA freshly created Tailscale container which has been joined to a tailnet, but not linked up with another service isn't really doing much. If we try to connect that container with `curl http://nginx-test` from another node on our tailnet, as expected, nothing happens—note the DNS resolution is working, there's just nothing to answer the request that came in.\r\n\r\n```\r\n# curl http://nginx-test\r\ncurl: (7) Failed to connect to nginx-test port 80 after 187 ms: Couldn't connect to server\r\n```\r\n\r\nIf we use `docker exec` to run a command inside the container, we can see that there are a handful of outbound `tailscaled` connections on ports `80` and `443`, these are to the Tailscale DERP servers and are part of the [NAT traversal](https://tailscale.com/blog/how-nat-traversal-works) process that makes Tailscale feel like magic.\r\n\r\n```\r\n# user@ubusrv2204:~$ docker exec -it ts-nginx-test netstat -pant\r\nActive Internet connections (servers and established)\r\nProto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name\r\ntcp        0      0 0.0.0.0:52382           0.0.0.0:*               LISTEN      10/tailscaled\r\ntcp        0      0 172.21.0.3:40436        3.125.149.81:80         ESTABLISHED 10/tailscaled\r\ntcp        0      0 172.21.0.3:46754        54.161.152.147:443      ESTABLISHED 10/tailscaled\r\ntcp        0      0 172.21.0.3:49334        199.38.182.118:443      ESTABLISHED 10/tailscaled\r\n```\r\n\r\nBut as soon as we add `network_mode: service:ts-nginx-test` to our nginx web service container, we get a much longer list of processes. Interestingly on `127.0.0.1` there is an unnamed process bound to port `80/tcp`. That's our nginx server! By linking these containers together using `network_mode` we are merging the containers network namespaces together inside the Linux kernel. In practice this means that these processes appear as if they are _inside_ the Tailscale container, even though _they are not running in it_. Containers are cool OK?\r\n\r\n```\r\n# user@ubusrv2204:~$ docker exec -it ts-nginx-test netstat -pant\r\nActive Internet connections (servers and established)\r\nProto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name\r\ntcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      -\r\ntcp        0      0 0.0.0.0:52382           0.0.0.0:*               LISTEN      12/tailscaled\r\ntcp        0      0 127.0.0.11:43379        0.0.0.0:*               LISTEN      -\r\ntcp        0      0 127.0.0.1:45072         127.0.0.1:80            ESTABLISHED 12/tailscaled\r\ntcp        0      0 127.0.0.1:45084         127.0.0.1:80            ESTABLISHED 12/tailscaled\r\ntcp        0      0 127.0.0.1:80            127.0.0.1:45072         ESTABLISHED -\r\ntcp        0      0 172.21.0.2:46468        199.38.181.93:443       ESTABLISHED 12/tailscaled\r\ntcp        0      0 172.21.0.2:60662        3.124.108.117:80        ESTABLISHED 12/tailscaled\r\ntcp        0      0 172.21.0.2:48084        54.161.152.147:443      ESTABLISHED 12/tailscaled\r\ntcp        0      0 127.0.0.1:80            127.0.0.1:45084         ESTABLISHED -\r\ntcp        0      0 :::80                   :::*                    LISTEN      -\r\n```\r\n\r\nThe same rules apply here as normal to any kind of interface port binding situation in so much that you may only have one service per port. We'll discuss a kind of workaround to this later if you'd like to only run one Tailscale container for some reason. The resource requirements per sidecar container are minimal, consuming typically less than 20mb of memory each. Therefore we recommend one sidecar container per service.\r\n\r\n## Remote access\r\n\r\nWith the above example if your container has a web service running on port 80, it will _just work_. But in the real world services run on all sorts of ports such as the excellent, and freely available self-hosted recipes app, [Mealie](https://mealie.io/) which runs on port 9000 by default. And what if we wanted to make these services available outside of your tailnet on the public internet? Enter Tailscale Serve and Funnel. We've covered them in some detail in this [_Tailscale Explained_](https://youtu.be/MpxmfpCl20c) video.\r\n\r\nAllow me to get a little excitable for a moment, and come with me as we imagine a future where your entire family's secret recipes are digitized and accessible by the whole family, _no matter where that information lives_! I can't tell you the number of times I've texted my mum for that special way she does potatoes or lost track of how I smoked a brisket successfully last time out. By putting apps on your tailnet, they are securely accessible only by people you trust and explicitly allow. No more port forwarding, dynamic IP addresses, or firewall rules. And thanks to the way Tailscale performs NAT punching each remote client is almost always able to establish a direct connection to your service without being relayed through any kind of proxy.\r\n\r\nWe're going to use Tailscale Serve and Funnel to expose this app to your tailnet and then the public internet. Here's the full docker compose YAML file we'll be using throughout the rest of this section. Mealie exposes many more [environment variables](https://nightly.mealie.io/documentation/getting-started/installation/backend-config/) but this config will suffice for our needs. Obviously you can substitute any other self-hosted application or service in the place of Mealie here, but remember that one Tailscale sidecar container per service is recommended for best results.\r\n\r\n```\r\n---\r\nversion: \"3.7\"\r\nservices:\r\n  ts-mealie:\r\n    image: tailscale/tailscale:latest\r\n    container_name: ts-mealie\r\n    hostname: mealie\r\n    environment:\r\n      - TS_AUTHKEY=tskey-client-kvtJAbRNotARealKey4d?ephemeral=false\r\n      - TS_EXTRA_ARGS=--advertise-tags=tag:container\r\n      - TS_SERVE_CONFIG=/config/mealie.json\r\n      - TS_STATE_DIR=/var/lib/tailscale\r\n    volumes:\r\n      - ${PWD}/ts-mealie/state:/var/lib/tailscale\r\n      - ${PWD}/ts-mealie/config:/config\r\n\t\tdevices:\r\n      - /dev/net/tun:/dev/net/tun\r\n    cap_add:\r\n      - net_admin\r\n      - sys_module\r\n    restart: unless-stopped\r\n  mealie:\r\n    image: ghcr.io/mealie-recipes/mealie:v1.0.0\r\n    container_name: mealie\r\n    network_mode: service:ts-mealie\r\n    depends_on:\r\n      - ts-mealie\r\n    volumes:\r\n      - mealie-data:/app/data/\r\n    environment:\r\n      - ALLOW_SIGNUP=true\r\n    restart: unless-stopped\r\n\r\nvolumes:\r\n  mealie-data:\r\n    driver: local\r\n  ts-mealie:\r\n    driver: local\r\n```\r\n\r\nPlace the contents above into a YAML file (our [VSCode extension](https://tailscale.com/kb/1265/vscode-extension) works really well for this!) in a separate directory named `compose.yaml`. If you don't yet have Docker installed on your host, you can follow [these quickstart directions to get going](https://github.com/docker/docker-install/#usage).\r\n\r\n## Tailnet prerequisites\r\n\r\nIn order to proceed we'll need to ensure a few things are in order.\r\n\r\n1. MagicDNS is enabled for your tailnet. This can be found under the [DNS](https://login.tailscale.com/admin/dns) section of your admin console. If you are so inclined, you might also want to roll the dice on our tailnet name generator to make your [DNS names](https://github.com/tailscale/tailscale/blob/main/words/tails.txt) more fun!\r\n2. If you intend to use Funnel, you'll need to enable in your access controls as per this [kb article](https://tailscale.com/kb/1223/funnel).\r\n\r\n   - Notably make sure that your `nodeAttrs` are configured to allow the Tailscale sidecar proxy container to allow Funnel traffic. You will only know the IP of the container doing this after you create it.\r\n3. Enable HTTPS under the [DNS](https://login.tailscale.com/admin/dns) section of your admin console\r\n\r\n## Configuring Serve and Funnel\r\n\r\nNext we're going to need to tell Serve and funnel how to proxy the traffic for our Mealie application. We do this by specifying the environment variable `TS_SERVE_CONFIG=/config/mealie.json` and putting a JSON file in the file path we specified in our `volumes:` section for the same parameter. For example, we're using the container internal file path `/config/mealie.json` mapped to `${PWD}/ts-mealie/config` on the host. `${PWD}` is a fancy way of writing \"the directory you're currently working in\"—this path should ideally be [an absolute path not a relative one](https://www.reddit.com/r/docker/comments/u996lx/why_absolute_paths_are_necessary_in_cli_but_not/). This path must also be a directory and not an individual file mount, otherwise changes made to the serve configuration will not be automatically detected by [fsnotify](https://github.com/fsnotify/fsnotify), the tool we use to detect changes to config files in real-time.\r\n\r\nYou might be wondering how you go about figuring out what the contents of that JSON file need to look like. The first option is easy—copy what's below and modify to your needs. The second option involves manually configuring Serve and Funnel manually and the printing out the resulting configuration with `tailscale serve status -json`. I'll leave the details of this as an exercise for the reader. For most people, copying what is below should suffice with only minor tweaks required.\r\n\r\n```\r\n# tailscale serve status -json\r\n{\r\n  \"TCP\": {\r\n    \"443\": {\r\n      \"HTTPS\": true\r\n    }\r\n  },\r\n  \"Web\": {\r\n    \"mealie.auto-generated.ts.net:443\": {\r\n      \"Handlers\": {\r\n        \"/\": {\r\n          \"Proxy\": \"http://127.0.0.1:9000\"\r\n        }\r\n      }\r\n    }\r\n  },\r\n  \"AllowFunnel\": {\r\n    \"mealie.auto-generated.ts.net:443\": true\r\n  }\r\n}\r\n```\r\n\r\nThis is all fine if you are comfortable hardcoding the DNS name for your application and tailnet into a configuration file, but that's not the best idea long term. What if the tailnet name changes? Or you rename the app? Or don't want to have to manually update a bunch of configuration files all the time. Well, in the immortal words of Professor Farnsworth: \"Good news everyone!\" We have a solution for that.\r\n\r\nSimply replace any hardcoded tailnet related values with `${TS_CERT_DOMAIN}` and on startup, the Tailscale sidecar proxy container will do all the heavy lifting of substituting those values as required. Your service will be available at `mealie.auto-generated.ts.net` as if by magic.\r\n\r\n```\r\n{\r\n  \"TCP\": {\r\n    \"443\": {\r\n      \"HTTPS\": true\r\n    }\r\n  },\r\n  \"Web\": {\r\n    \"${TS_CERT_DOMAIN}:443\": {\r\n      \"Handlers\": {\r\n        \"/\": {\r\n          \"Proxy\": \"http://127.0.0.1:9000\"\r\n        }\r\n      }\r\n    }\r\n  },\r\n  \"AllowFunnel\": {\r\n    \"${TS_CERT_DOMAIN}:443\": false\r\n  }\r\n}\r\n```\r\n\r\nBreaking down the syntax of this configuration file, we are proxying `http://127.0.0.1:9000` any time a request to the root of the domain (signified with `/`) is received. In other words I type `mealie.auto-genereated.ts.net` into my browser, the request is routed to the sidecar proxy which matches the traffic as a request for the root and returns the contents of the service running at `http://127.0.0.1:9000` to the requestor.\r\n\r\nNeed to expose the app to the internet? You should make that decision carefully, but then it's simple. Set the value of `AllowFunnel` to `true` and after a few seconds anyone on the internet will be able to reach the service.\r\n\r\n## Running the app\r\n\r\nTook us a while to get here didn't it? Phew. I'm glad you're still here. Running the app is the easy bit, thankfully. On your Docker host, execute `docker compose up -d` followed by `docker compose logs -f` to follow the log output of the new containers just incase there are an issues with Tailscale authentication (a copy paste or formatting error, or expired keys are the most common problems here).\r\n\r\n```\r\nmealie     | INFO:     Uvicorn running on http://0.0.0.0:9000 (Press CTRL+C to quit)\r\n```\r\n\r\nWait for the line in the logs and the check your Tailscale admin dashboard. Run `docker exec -it ts-mealie tailscale status` to print the current tailnet status. This command executes inside the context of the `ts-mealie` container we just created so what it prints out here is the world view as the container sees it.\r\n\r\n```\r\n# docker exec -it ts-mealie tailscale status\r\n100.71.14.42    mealie               mealie.velociraptor-noodlefish.ts.net linux   -\r\n100.96.138.32   docker-host          atailandscales@ linux   -\r\n100.99.182.58   opnsense-101         atailandscales@ freebsd -\r\n```\r\n\r\n![mealie-screenshot](https://cdn.sanity.io/images/w77i7m8x/production/aa1e880da0bd6ae697e0c8f2674ea4eb238746c2-1011x983.png?w=450)\r\n\r\n## Recap\r\n\r\nAnd voila! We have our self-hosted recipes app running natively on our tailnet with a valid HTTPS certificate, available both internally to other tailnet devices and externally if you are using Funnel.\r\n\r\nThis approach scales to multiple containers, operating systems, network architectures, and more. And with things like being able to now [choose your own IP](https://tailscale.com/blog/choose-your-ip) you should have more than enough flexibility to meet any of your needs in the containerization space.\r\n\r\nIf you're already a regular user of Tailscale and Docker we'd love to hear from you on how you're using it so that we can make it even better in the future. We have big plans but need your help finalizing what's most important. Let us know! Thanks for reading.\r\n\r\nShare\r\n\r\nAuthor\r\n\r\n![Alex Kretzschmar](https://cdn.sanity.io/images/w77i7m8x/production/888a281fcb0dc90f05d8fd76c1dd592efb8eefe6-512x512.jpg?w=1080&q=75&fit=clip&auto=format)Alex Kretzschmar\r\n\r\nAuthor\r\n\r\n![Alex Kretzschmar](https://cdn.sanity.io/images/w77i7m8x/production/888a281fcb0dc90f05d8fd76c1dd592efb8eefe6-512x512.jpg?w=1080&q=75&fit=clip&auto=format)Alex Kretzschmar\r\n\r\nShare\r\n\r\n## More articles\r\n\r\n[productApr 15, 2026\\\\\r\n**Meet tailscale-rs, our new Rust library preview** \\\\\r\nAn early preview of tailscale-rs is out now, and we’d like your help testing what comes next.\\\\\r\n\\\\\r\n![David Anderson](https://cdn.sanity.io/images/w77i7m8x/production/c1d565287da262060d5300fef5cbce3953d78617-1200x1197.jpg?w=3840&q=75&fit=clip&auto=format)\\\\\r\n\\\\\r\nDavid Anderson](https://tailscale.com/blog/tailscale-rs-rust-tsnet-library-preview) [productApr 08, 2026\\\\\r\n**Pricing v4: more value, more simply** \\\\\r\nTailscale’s updated plans are now live. The free Personal plan is more generous, business plans include more features, and pricing is easier to understand and predict.\\\\\r\n\\\\\r\n![Avery Pennarun Headshot](https://cdn.sanity.io/images/w77i7m8x/production/07510b1cfc4243485b30ed540b821e5c6a928beb-600x559.jpg?w=1200&q=75&fit=clip&auto=format)\\\\\r\n\\\\\r\nAvery Pennarun](https://tailscale.com/blog/pricing-v4) [insightsApr 02, 2026\\\\\r\n**Being the adult in the room** \\\\\r\nThe industry is moving quickly. The hard part is not losing your footing.\\\\\r\n\\\\\r\n![Avery Pennarun Headshot](https://cdn.sanity.io/images/w77i7m8x/production/07510b1cfc4243485b30ed540b821e5c6a928beb-600x559.jpg?w=1200&q=75&fit=clip&auto=format)\\\\\r\n\\\\\r\nAvery Pennarun](https://tailscale.com/blog/adult-in-the-room)\r\n\r\n## Subscribe to Tailscale’s blog\r\n\r\nWe have a deep commitment to keeping your data safe.\r\n\r\nEmail\\*\r\n\r\nToo much email?[RSS Feed](https://tailscale.com/blog/index.xml) RSS[Tailscale on twitter](https://twitter.com/tailscale) Twitter\r\n\r\n## Try Tailscale for free\r\n\r\n[Get started](https://login.tailscale.com/start)\r\n\r\nSchedule a demo\r\n\r\n[Contact sales](https://tailscale.com/contact/sales)\r\n\r\n![cta phone](https://cdn.sanity.io/images/w77i7m8x/production/b715b4ca5e2577da60f0d529a4a9bc2ad4cadf59-362x567.svg?w=750&q=75&fit=clip&auto=format)\r\n\r\n![mercury](https://cdn.sanity.io/images/w77i7m8x/production/459a7a8492910eeb22f22bb8d4c0f864b0bae25f-199x81.svg?w=640&q=75&fit=clip&auto=format)\r\n\r\n![instacrt](https://cdn.sanity.io/images/w77i7m8x/production/7d127f4bb62a408b056328349f291857df6251b3-199x81.svg?w=640&q=75&fit=clip&auto=format)\r\n\r\n![Retool](https://cdn.sanity.io/images/w77i7m8x/production/e9579b00087d7896e9cb750f4eb39f2c11ed11b8-199x82.svg?w=640&q=75&fit=clip&auto=format)\r\n\r\n![duolingo](https://cdn.sanity.io/images/w77i7m8x/production/7958bf3d43a30e661ca74cf0510f250d9b99ecef-199x81.svg?w=640&q=75&fit=clip&auto=format)\r\n\r\n![Hugging Face](https://cdn.sanity.io/images/w77i7m8x/production/68e2e5024898bcd6f6d142e0306dc7564787e1d7-199x82.svg?w=640&q=75&fit=clip&auto=format)\r\n","html":"<p><a href=\"https://tailscale.com/blog\">Blog</a> |insightsFebruary 07, 2024</p>\n<h1>Contain your excitement: A deep dive into using Tailscale with Docker</h1>\n<p><img src=\"https://cdn.sanity.io/images/w77i7m8x/production/ace904acf6a3886bf19faf2f56bf223bfec1b995-600x315.svg?w=1200&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"Orange shapes on a dark orange background\"></p>\n<p>Creating small, trusted networks with your friends, family and coworkers is central to our mission here at Tailscale. In this article we're going to dive deep into using Docker to do just that, with containers.</p>\n<p>Why might you want to consider putting Tailscale in a container? By design, containers are isolated execution environments. Access to resources both inside and outside of these container environments must be explicitly granted and managed.</p>\n<p>By putting containers directly onto your tailnet (our term for a Tailscale network), you're able to connect these isolated things together no matter the networking topology that stands in your way.</p>\n<p>Each container becomes a fully-fledged node on your tailnet, just like any other. This means no reverse proxies. No complex firewall rules or port forwarding. And no matter how bad your NAT situation, Tailscale will traverse it.</p>\n<p>A deep dive into using Tailscale with Docker - YouTube</p>\n<p>Tap to unmute</p>\n<p><a href=\"https://www.youtube.com/watch?v=tqvvZhGrciQ\">A deep dive into using Tailscale with Docker</a> <a href=\"https://www.youtube.com/channel/UCcdv38QxPjSMqbt5ffLhJLA\">Tailscale</a></p>\n<p><img src=\"https://yt3.ggpht.com/K3B5Hdi7j4zy2y53nlQCQb9J0qHNuSXhD2hWysrwaiDvv19tPr5RY04QuQfFOvb9rDq9cugGjZU=s68-c-k-c0x00ffffff-no-rj\" alt=\"thumbnail-image\"></p>\n<p>Tailscale68.5K subscribers</p>\n<p><a href=\"https://www.youtube.com/watch?v=tqvvZhGrciQ\">Watch on</a></p>\n<p>This post is also available as a YouTube video, and copies of the files used throughout are available in <a href=\"https://github.com/tailscale-dev/docker-guide-code-examples\">this GitHub repo</a>. To make the most of this article some familiarity with Docker, Docker Compose, and general networking concepts is advised.</p>\n<p>Today we're going to cover adding a container directly to your tailnet with auth keys, and OAuth secrets (and the difference between them), plus how to proxy a web service through Tailscale both locally via Serve and over the public internet via Funnel.</p>\n<h2>Adding a container to your tailnet</h2>\n<p>Tailscale offers an official <a href=\"https://tailscale.com/kb/1282/docker\">Docker container</a> which provides several parameters we'll need to configure via environment variables. A full list of those variables is provided in our <a href=\"https://tailscale.com/kb/1282/docker#parameters\">documentation</a>.</p>\n<p>There are two primary methods to add a container to your tailnet. Option 1 is to use an auth key, and option 2 is using OAuth secrets—we'll cover both here as they can be useful in different scenarios.</p>\n<h3>Should I use Auth Keys or OAuth secrets?</h3>\n<p>Can we just agree that this is a classic \"it depends\" situation? <em>[editor: no.]</em> Ok, fine.</p>\n<p>Before we dig into how to use them, let's figure out which authentication method is right for you. Both methods support a lot of the same things and both will work for many of you. However, there are subtle differences between these two authentication methods which may make or break your use case.</p>\n<p>| Auth keys | OAuth clients |\r\n| --- | --- |\r\n| ✅ Grant full API access | ✅ Limits API access via scopes e.g. <code>dns:read</code> |\r\n| ✅ Maximum lifespan of 90 days | ✅ Never expires |\r\n| ✅ Doesn't require tags | ✅ Requires tags for created nodes |\r\n| ✅ Authenticates the machine as the user who generated the key | ✅ Authenticates nodes via <code>tagOwner</code> tag |</p>\n<p>In order to better understand some of the nuance here, let's examine node ownership and tags. When a node is authenticated to your tailnet via <code>tailscale up</code>, the user who logged into the admin console and clicked the generate button owns that node. That ownership manifests as a tag, which is why we refer to them in ACLs as <code>tagOwners</code>. Each node on a tailnet must have an owner, whether it's a user or a tag.</p>\n<p>When you use an auth key, the node is added to your tailnet as the user who generated the key. With an OAuth client the node is owned by the tag assigned at secret creation time.</p>\n<p>Another crucial difference is token / key expiry. On the face of it, it <em>appears</em> that an auth key will be useless after the maximum 90 day expiry window. However, when an auth key expires it doesn't automatically mean any machine which used it to authenticate to your tailnet is suddenly expired as well—it simply means you can't use that key to add any <em>new</em> nodes, existing ones continue to function until their <a href=\"https://tailscale.com/kb/1028/key-expiry\">node keys expire</a> (default is 180 days). Nodes added with an OAuth client never expire.</p>\n<p>Now we know some of the nuances involved, let's dig into added a container to your tailnet using these methods.</p>\n<h3>Using Auth Keys</h3>\n<p>To use an auth key we need to generate one using the admin console. Navigate to <a href=\"https://login.tailscale.com/admin/settings/keys\">https://login.tailscale.com/admin/settings/keys</a> and select <strong>Generate auth key</strong>. The most up to date information on generating auth keys and the available parameters can be found <a href=\"https://tailscale.com/kb/1085/auth-keys#about-auth-keys\">in this kb article</a>. The values you select for your auth key will be highly specific to your environment, but for our purposes here let's just use the following to get you started:</p>\n<ul>\n<li>Description: <code>docker-testing</code></li>\n<li>Reusable: <code>yes</code></li>\n<li>Expiration: <code>7 days</code></li>\n<li>Ephemeral: <code>No</code></li>\n<li>Tags: <code>tag:container</code></li>\n</ul>\n<p>Note that in order to use a tag, it must first be defined in your <a href=\"https://tailscale.com/kb/1018/acls\">Access control policy</a> like so:</p>\n<pre><code>\"tagOwners\": {\r\n\t\"tag:container\": [\"autogroup:admin\"],\r\n},\n</code></pre>\n<p>If you're feeling adventurous or programmatic is your middle name, we also provide a utility named <code>get-authkey</code> to <a href=\"https://tailscale.com/kb/1215/oauth-clients#get-authkey-utility\">automate this process</a>.</p>\n<p>Once you have an auth key (do not share or lose this, treat it like a password), we want to plug it into our container using <code>TS_AUTHKEY=tskey-auth-123abc...</code> as an environment variable (see below).</p>\n<p>It's vitally important that the container is able to persist the Tailscale session state across reboots and recreation events. To do this we need to map <code>/var/lib/tailscale</code> to a volume (see below for an example), and also explicitly configure the path the container will use to store state using the environment variable <code>TS_STATE_DIR=/var/lib/tailscale</code>.</p>\n<p>Here's a full example docker compose YAML file showing a minimum viable configuration to get a simple nginx based web server onto your tailnet. When <code>docker compose up</code> is run against this file a node will be added to your tailnet with the name <code>webserver1</code>. All the usual Tailscale goodies like MagicDNS, subnet routing, and access controls via ACLs, are available inside the context of this container. It's just another node on your tailnet, after all.</p>\n<pre><code class=\"language-yaml\"># compose-authkey.yaml\r\n---\r\nversion: \"3.7\"\r\nservices:\r\n  ts-webserver1:\r\n    image: tailscale/tailscale:latest\r\n    hostname: webserver1\r\n    environment:\r\n      - TS_AUTHKEY=tskey-auth-k7TsXC6CNTRL-D9notarealAuthKey2NFQQcUvtP9WJ\r\n      - TS_STATE_DIR=/var/lib/tailscale\r\n    volumes:\r\n      - tailscale-data-webserver1:/var/lib/tailscale\r\n\t\tdevices:\r\n      - /dev/net/tun:/dev/net/tun\r\n    cap_add:\r\n      - net_admin\r\n      - sys_module\r\n    restart: unless-stopped\r\n  webserver1:\r\n      image: nginx\r\n      network_mode: service:ts-webserver1\r\n      depends_on:\r\n        - ts-webserver1\r\nvolumes:\r\n  tailscale-data-webserver1:\r\n    driver: local\n</code></pre>\n<p>We've deployed a Tailscale docker container alongside an nginx web server container. The <code>network_mode</code> setting on the nginx container routes all traffic for that container via the Tailscale container—this is often referred to as a \"sidecar\" container. In this fashion we can effectively directly deploy multiple individual services to our tailnet with unique names matching the service. This is a deployment pattern we'll come back to later on in this post, so don't worry if the details don't quite click yet.</p>\n<p>One last point on auth keys. There is a common misconception that auth keys aren't useful for long running workloads due to the fact that they have a maximum life span of 90 days. However, if a node comes up tagged then key expiry is automatically disabled. Furthermore, so long as the container is able to persist state, removing the auth key completely (or it expiring) has no impact once the node has joined your tailnet. What will be affected after the auth key expires is the ability to join <em>new</em> nodes to your tailnet without rotating the key.</p>\n<h3>Using OAuth</h3>\n<p>OAuth clients provide a framework for delegated and scoped access to the <a href=\"https://tailscale.com/kb/1101/api\">Tailscale API</a>. In other words, unlike an auth key which is fully scoped (i.e., if you have this key you can do anything), OAuth clients allow fine-grained control on the access granted to client. You can get a better idea of the scopes offered in the <a href=\"https://tailscale.com/kb/1215/oauth-clients#scopes\">documentation</a> but we're going to focus on the <code>Auth Keys: Write</code> scope. This limits us to auth key–related actions and we cannot, for example, modify ACLs or DNS on clients authenticated with this token. If the word auditor is in regular use in your vocabulary, then OAuth should be on your radar.</p>\n<p>But how does one go about using an OAuth client token with Docker? Instead of using <code>TS_AUTHKEY=tskey-auth-blah</code> we replace that value with our OAuth client secret, and define another environment variable stating our desired tag like <code>TS_EXTRA_ARGS=--advertise-tags=tag:container</code>. Behind the scenes, the Tailscale container will execute <code>tailscale up</code>, recognize this is an OAuth client secret and use that mechanism to automatically generate an auth key for you. The outcome is a container on your tailnet all the same.</p>\n<p>To generate an OAuth client token:</p>\n<ol>\n<li>visit <a href=\"https://login.tailscale.com/admin/settings/oauth\">https://login.tailscale.com/admin/settings/oauth</a></li>\n<li>click <strong>Generate OAuth client</strong></li>\n<li>fill out the description</li>\n<li>select <code>Auth Keys: Write</code>\n<ul>\n<li>(note <code>Auth Keys: Read</code> will be automatically selected too)</li>\n</ul>\n</li>\n<li>select an appropriate tag\n<ul>\n<li>in our example we created the tag <code>container</code></li>\n<li>as in the auth key section you must have already defined an ACL tag before this step in your ACLs</li>\n</ul>\n</li>\n<li>then it's time to <strong>Generate Client</strong></li>\n</ol>\n<p>The <code>Client ID</code> is not important for our purposes, nor is it particularly sensitive. The <code>Client secret</code> however, should be treated like a password - it is a secret after all. This key is only shown once and cannot be rotated. To rotate a OAuth client secret you must revoke and recreate a new one.</p>\n<p>Here's the same example from above but configured to use OAuth instead of auth keys:</p>\n<pre><code># compose-oauth.yaml\r\n---\r\nversion: \"3.7\"\r\nservices:\r\n  ts-webserver2:\r\n    image: tailscale/tailscale:latest\r\n    hostname: webserver2\r\n    environment:\r\n      - TS_AUTHKEY=tskey-client-kXGGbs6CNTRL-wXGXnotarealsecret1U3aeeaj\r\n      - TS_EXTRA_ARGS=--advertise-tags=tag:container\r\n      - TS_STATE_DIR=/var/lib/tailscale\r\n    volumes:\r\n      - tailscale-data-webserver2:/var/lib/tailscale\r\n\t\tdevices:\r\n      - /dev/net/tun:/dev/net/tun\r\n    cap_add:\r\n      - net_admin\r\n      - sys_module\r\n    restart: unless-stopped\r\n  webserver2:\r\n      image: nginx\r\n      network_mode: service:ts-webserver2\r\n      depends_on:\r\n        - ts-webserver2\r\nvolumes:\r\n  tailscale-data-webserver2:\r\n    driver: local\n</code></pre>\n<p>As was the case in our previous auth key example, this compose snippet will create a container and join it to your tailnet automatically with the hostname <code>webserver2</code>. We're also creating that same nginx web server, which proxies all its traffic via the Tailscale container thanks to the <code>network_mode: service:ts-webserver2</code> parameter.</p>\n<p>Those of you paying attention might notice that by default an OAuth client authenticated node is marked as <code>ephemeral</code>. In other words as soon as you stop the container (or very soon after), it is removed from your tailnet altogether. This might be really handy if it's a temporary thing like a CI build but for other services we want more permanence. To this end, append the argument <code>?ephemeral=false</code> to <code>TS_AUTHKEY</code> in the form <code>TS_AUTHKEY=tskey-client-kXGGbs6CNTRL-wXGXnotarealsecret1U3aeeaj?ephemeral=false</code>. Our documentation includes information about <a href=\"https://tailscale.com/kb/1215/oauth-clients#registering-new-nodes-using-oauth-credentials\">the other arguments available</a>.</p>\n<h2>Service linking</h2>\n<p>Now we know how to join a container to your tailnet, let's take a look at the mechanics of what linking a sidecar container to your service is actually doing. Documentation from Docker on what exactly <code>network_mode: service: &#x3C;service-name></code> does is a little sparse—see its <a href=\"https://docs.docker.com/network/#container-networks\">section on container networks</a> and <a href=\"https://docs.docker.com/compose/compose-file/compose-file-v3/#network_mode\"><code>network_mode</code> in particular</a>. However, we can examine what's going on for ourselves easily enough.</p>\n<p>Here is an example docker compose file which instantiates a web service container, and a Tailscale container acting as a sidecar proxy to the web service.</p>\n<pre><code>---\r\nversion: \"3.7\"\r\nservices:\r\n  ts-nginx-test:\r\n    image: tailscale/tailscale:latest\r\n    container_name: ts-nginx-test\r\n    hostname: nginx-test\r\n    environment:\r\n      - TS_AUTHKEY=tskey-auth-kvt6L96CNTRL-SnotarealkeyTUxNkuuYb14d\r\n      - TS_STATE_DIR=/var/lib/tailscale\r\n    volumes:\r\n      - ${PWD}/ts-nginx-test/state:/var/lib/tailscale\r\n\t\tdevices:\r\n      - /dev/net/tun:/dev/net/tun\r\n    cap_add:\r\n      - net_admin\r\n      - sys_module\r\n    restart: unless-stopped\r\n  nginx-test:\r\n    image: nginx\r\n    network_mode: service:ts-nginx-test  # &#x3C;-- this is where the magic happens\r\n\r\nvolumes:\r\n  ts-alpine:\r\n    driver: local\n</code></pre>\n<p>The end result is that we can visit <code>http://nginx-test</code> in our browser and reach the nginx service hello world web page. If we execute <code>curl http://nginx-test</code> from another node on our tailnet, we receive a response showing that traffic is flowing through the sidecar container to the underlying nginx container. There are no ports mapped to the host from either container, nor any other method of data ingress or egress except via Tailscale.</p>\n<pre><code># curl http://nginx-test\r\n&#x3C;html>\r\n&#x3C;h1>Welcome to nginx!&#x3C;/h1>\r\n&#x3C;p>If you see this page, the nginx web server is successfully installed and\r\nworking. Further configuration is required.&#x3C;/p>\r\n&#x3C;/html>\n</code></pre>\n<p>A freshly created Tailscale container which has been joined to a tailnet, but not linked up with another service isn't really doing much. If we try to connect that container with <code>curl http://nginx-test</code> from another node on our tailnet, as expected, nothing happens—note the DNS resolution is working, there's just nothing to answer the request that came in.</p>\n<pre><code># curl http://nginx-test\r\ncurl: (7) Failed to connect to nginx-test port 80 after 187 ms: Couldn't connect to server\n</code></pre>\n<p>If we use <code>docker exec</code> to run a command inside the container, we can see that there are a handful of outbound <code>tailscaled</code> connections on ports <code>80</code> and <code>443</code>, these are to the Tailscale DERP servers and are part of the <a href=\"https://tailscale.com/blog/how-nat-traversal-works\">NAT traversal</a> process that makes Tailscale feel like magic.</p>\n<pre><code># user@ubusrv2204:~$ docker exec -it ts-nginx-test netstat -pant\r\nActive Internet connections (servers and established)\r\nProto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name\r\ntcp        0      0 0.0.0.0:52382           0.0.0.0:*               LISTEN      10/tailscaled\r\ntcp        0      0 172.21.0.3:40436        3.125.149.81:80         ESTABLISHED 10/tailscaled\r\ntcp        0      0 172.21.0.3:46754        54.161.152.147:443      ESTABLISHED 10/tailscaled\r\ntcp        0      0 172.21.0.3:49334        199.38.182.118:443      ESTABLISHED 10/tailscaled\n</code></pre>\n<p>But as soon as we add <code>network_mode: service:ts-nginx-test</code> to our nginx web service container, we get a much longer list of processes. Interestingly on <code>127.0.0.1</code> there is an unnamed process bound to port <code>80/tcp</code>. That's our nginx server! By linking these containers together using <code>network_mode</code> we are merging the containers network namespaces together inside the Linux kernel. In practice this means that these processes appear as if they are <em>inside</em> the Tailscale container, even though <em>they are not running in it</em>. Containers are cool OK?</p>\n<pre><code># user@ubusrv2204:~$ docker exec -it ts-nginx-test netstat -pant\r\nActive Internet connections (servers and established)\r\nProto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name\r\ntcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      -\r\ntcp        0      0 0.0.0.0:52382           0.0.0.0:*               LISTEN      12/tailscaled\r\ntcp        0      0 127.0.0.11:43379        0.0.0.0:*               LISTEN      -\r\ntcp        0      0 127.0.0.1:45072         127.0.0.1:80            ESTABLISHED 12/tailscaled\r\ntcp        0      0 127.0.0.1:45084         127.0.0.1:80            ESTABLISHED 12/tailscaled\r\ntcp        0      0 127.0.0.1:80            127.0.0.1:45072         ESTABLISHED -\r\ntcp        0      0 172.21.0.2:46468        199.38.181.93:443       ESTABLISHED 12/tailscaled\r\ntcp        0      0 172.21.0.2:60662        3.124.108.117:80        ESTABLISHED 12/tailscaled\r\ntcp        0      0 172.21.0.2:48084        54.161.152.147:443      ESTABLISHED 12/tailscaled\r\ntcp        0      0 127.0.0.1:80            127.0.0.1:45084         ESTABLISHED -\r\ntcp        0      0 :::80                   :::*                    LISTEN      -\n</code></pre>\n<p>The same rules apply here as normal to any kind of interface port binding situation in so much that you may only have one service per port. We'll discuss a kind of workaround to this later if you'd like to only run one Tailscale container for some reason. The resource requirements per sidecar container are minimal, consuming typically less than 20mb of memory each. Therefore we recommend one sidecar container per service.</p>\n<h2>Remote access</h2>\n<p>With the above example if your container has a web service running on port 80, it will <em>just work</em>. But in the real world services run on all sorts of ports such as the excellent, and freely available self-hosted recipes app, <a href=\"https://mealie.io/\">Mealie</a> which runs on port 9000 by default. And what if we wanted to make these services available outside of your tailnet on the public internet? Enter Tailscale Serve and Funnel. We've covered them in some detail in this <a href=\"https://youtu.be/MpxmfpCl20c\"><em>Tailscale Explained</em></a> video.</p>\n<p>Allow me to get a little excitable for a moment, and come with me as we imagine a future where your entire family's secret recipes are digitized and accessible by the whole family, <em>no matter where that information lives</em>! I can't tell you the number of times I've texted my mum for that special way she does potatoes or lost track of how I smoked a brisket successfully last time out. By putting apps on your tailnet, they are securely accessible only by people you trust and explicitly allow. No more port forwarding, dynamic IP addresses, or firewall rules. And thanks to the way Tailscale performs NAT punching each remote client is almost always able to establish a direct connection to your service without being relayed through any kind of proxy.</p>\n<p>We're going to use Tailscale Serve and Funnel to expose this app to your tailnet and then the public internet. Here's the full docker compose YAML file we'll be using throughout the rest of this section. Mealie exposes many more <a href=\"https://nightly.mealie.io/documentation/getting-started/installation/backend-config/\">environment variables</a> but this config will suffice for our needs. Obviously you can substitute any other self-hosted application or service in the place of Mealie here, but remember that one Tailscale sidecar container per service is recommended for best results.</p>\n<pre><code>---\r\nversion: \"3.7\"\r\nservices:\r\n  ts-mealie:\r\n    image: tailscale/tailscale:latest\r\n    container_name: ts-mealie\r\n    hostname: mealie\r\n    environment:\r\n      - TS_AUTHKEY=tskey-client-kvtJAbRNotARealKey4d?ephemeral=false\r\n      - TS_EXTRA_ARGS=--advertise-tags=tag:container\r\n      - TS_SERVE_CONFIG=/config/mealie.json\r\n      - TS_STATE_DIR=/var/lib/tailscale\r\n    volumes:\r\n      - ${PWD}/ts-mealie/state:/var/lib/tailscale\r\n      - ${PWD}/ts-mealie/config:/config\r\n\t\tdevices:\r\n      - /dev/net/tun:/dev/net/tun\r\n    cap_add:\r\n      - net_admin\r\n      - sys_module\r\n    restart: unless-stopped\r\n  mealie:\r\n    image: ghcr.io/mealie-recipes/mealie:v1.0.0\r\n    container_name: mealie\r\n    network_mode: service:ts-mealie\r\n    depends_on:\r\n      - ts-mealie\r\n    volumes:\r\n      - mealie-data:/app/data/\r\n    environment:\r\n      - ALLOW_SIGNUP=true\r\n    restart: unless-stopped\r\n\r\nvolumes:\r\n  mealie-data:\r\n    driver: local\r\n  ts-mealie:\r\n    driver: local\n</code></pre>\n<p>Place the contents above into a YAML file (our <a href=\"https://tailscale.com/kb/1265/vscode-extension\">VSCode extension</a> works really well for this!) in a separate directory named <code>compose.yaml</code>. If you don't yet have Docker installed on your host, you can follow <a href=\"https://github.com/docker/docker-install/#usage\">these quickstart directions to get going</a>.</p>\n<h2>Tailnet prerequisites</h2>\n<p>In order to proceed we'll need to ensure a few things are in order.</p>\n<ol>\n<li>\n<p>MagicDNS is enabled for your tailnet. This can be found under the <a href=\"https://login.tailscale.com/admin/dns\">DNS</a> section of your admin console. If you are so inclined, you might also want to roll the dice on our tailnet name generator to make your <a href=\"https://github.com/tailscale/tailscale/blob/main/words/tails.txt\">DNS names</a> more fun!</p>\n</li>\n<li>\n<p>If you intend to use Funnel, you'll need to enable in your access controls as per this <a href=\"https://tailscale.com/kb/1223/funnel\">kb article</a>.</p>\n<ul>\n<li>Notably make sure that your <code>nodeAttrs</code> are configured to allow the Tailscale sidecar proxy container to allow Funnel traffic. You will only know the IP of the container doing this after you create it.</li>\n</ul>\n</li>\n<li>\n<p>Enable HTTPS under the <a href=\"https://login.tailscale.com/admin/dns\">DNS</a> section of your admin console</p>\n</li>\n</ol>\n<h2>Configuring Serve and Funnel</h2>\n<p>Next we're going to need to tell Serve and funnel how to proxy the traffic for our Mealie application. We do this by specifying the environment variable <code>TS_SERVE_CONFIG=/config/mealie.json</code> and putting a JSON file in the file path we specified in our <code>volumes:</code> section for the same parameter. For example, we're using the container internal file path <code>/config/mealie.json</code> mapped to <code>${PWD}/ts-mealie/config</code> on the host. <code>${PWD}</code> is a fancy way of writing \"the directory you're currently working in\"—this path should ideally be <a href=\"https://www.reddit.com/r/docker/comments/u996lx/why_absolute_paths_are_necessary_in_cli_but_not/\">an absolute path not a relative one</a>. This path must also be a directory and not an individual file mount, otherwise changes made to the serve configuration will not be automatically detected by <a href=\"https://github.com/fsnotify/fsnotify\">fsnotify</a>, the tool we use to detect changes to config files in real-time.</p>\n<p>You might be wondering how you go about figuring out what the contents of that JSON file need to look like. The first option is easy—copy what's below and modify to your needs. The second option involves manually configuring Serve and Funnel manually and the printing out the resulting configuration with <code>tailscale serve status -json</code>. I'll leave the details of this as an exercise for the reader. For most people, copying what is below should suffice with only minor tweaks required.</p>\n<pre><code># tailscale serve status -json\r\n{\r\n  \"TCP\": {\r\n    \"443\": {\r\n      \"HTTPS\": true\r\n    }\r\n  },\r\n  \"Web\": {\r\n    \"mealie.auto-generated.ts.net:443\": {\r\n      \"Handlers\": {\r\n        \"/\": {\r\n          \"Proxy\": \"http://127.0.0.1:9000\"\r\n        }\r\n      }\r\n    }\r\n  },\r\n  \"AllowFunnel\": {\r\n    \"mealie.auto-generated.ts.net:443\": true\r\n  }\r\n}\n</code></pre>\n<p>This is all fine if you are comfortable hardcoding the DNS name for your application and tailnet into a configuration file, but that's not the best idea long term. What if the tailnet name changes? Or you rename the app? Or don't want to have to manually update a bunch of configuration files all the time. Well, in the immortal words of Professor Farnsworth: \"Good news everyone!\" We have a solution for that.</p>\n<p>Simply replace any hardcoded tailnet related values with <code>${TS_CERT_DOMAIN}</code> and on startup, the Tailscale sidecar proxy container will do all the heavy lifting of substituting those values as required. Your service will be available at <code>mealie.auto-generated.ts.net</code> as if by magic.</p>\n<pre><code>{\r\n  \"TCP\": {\r\n    \"443\": {\r\n      \"HTTPS\": true\r\n    }\r\n  },\r\n  \"Web\": {\r\n    \"${TS_CERT_DOMAIN}:443\": {\r\n      \"Handlers\": {\r\n        \"/\": {\r\n          \"Proxy\": \"http://127.0.0.1:9000\"\r\n        }\r\n      }\r\n    }\r\n  },\r\n  \"AllowFunnel\": {\r\n    \"${TS_CERT_DOMAIN}:443\": false\r\n  }\r\n}\n</code></pre>\n<p>Breaking down the syntax of this configuration file, we are proxying <code>http://127.0.0.1:9000</code> any time a request to the root of the domain (signified with <code>/</code>) is received. In other words I type <code>mealie.auto-genereated.ts.net</code> into my browser, the request is routed to the sidecar proxy which matches the traffic as a request for the root and returns the contents of the service running at <code>http://127.0.0.1:9000</code> to the requestor.</p>\n<p>Need to expose the app to the internet? You should make that decision carefully, but then it's simple. Set the value of <code>AllowFunnel</code> to <code>true</code> and after a few seconds anyone on the internet will be able to reach the service.</p>\n<h2>Running the app</h2>\n<p>Took us a while to get here didn't it? Phew. I'm glad you're still here. Running the app is the easy bit, thankfully. On your Docker host, execute <code>docker compose up -d</code> followed by <code>docker compose logs -f</code> to follow the log output of the new containers just incase there are an issues with Tailscale authentication (a copy paste or formatting error, or expired keys are the most common problems here).</p>\n<pre><code>mealie     | INFO:     Uvicorn running on http://0.0.0.0:9000 (Press CTRL+C to quit)\n</code></pre>\n<p>Wait for the line in the logs and the check your Tailscale admin dashboard. Run <code>docker exec -it ts-mealie tailscale status</code> to print the current tailnet status. This command executes inside the context of the <code>ts-mealie</code> container we just created so what it prints out here is the world view as the container sees it.</p>\n<pre><code># docker exec -it ts-mealie tailscale status\r\n100.71.14.42    mealie               mealie.velociraptor-noodlefish.ts.net linux   -\r\n100.96.138.32   docker-host          atailandscales@ linux   -\r\n100.99.182.58   opnsense-101         atailandscales@ freebsd -\n</code></pre>\n<p><img src=\"https://cdn.sanity.io/images/w77i7m8x/production/aa1e880da0bd6ae697e0c8f2674ea4eb238746c2-1011x983.png?w=450\" alt=\"mealie-screenshot\"></p>\n<h2>Recap</h2>\n<p>And voila! We have our self-hosted recipes app running natively on our tailnet with a valid HTTPS certificate, available both internally to other tailnet devices and externally if you are using Funnel.</p>\n<p>This approach scales to multiple containers, operating systems, network architectures, and more. And with things like being able to now <a href=\"https://tailscale.com/blog/choose-your-ip\">choose your own IP</a> you should have more than enough flexibility to meet any of your needs in the containerization space.</p>\n<p>If you're already a regular user of Tailscale and Docker we'd love to hear from you on how you're using it so that we can make it even better in the future. We have big plans but need your help finalizing what's most important. Let us know! Thanks for reading.</p>\n<p>Share</p>\n<p>Author</p>\n<p><img src=\"https://cdn.sanity.io/images/w77i7m8x/production/888a281fcb0dc90f05d8fd76c1dd592efb8eefe6-512x512.jpg?w=1080&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"Alex Kretzschmar\">Alex Kretzschmar</p>\n<p>Author</p>\n<p><img src=\"https://cdn.sanity.io/images/w77i7m8x/production/888a281fcb0dc90f05d8fd76c1dd592efb8eefe6-512x512.jpg?w=1080&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"Alex Kretzschmar\">Alex Kretzschmar</p>\n<p>Share</p>\n<h2>More articles</h2>\n<p><a href=\"https://tailscale.com/blog/tailscale-rs-rust-tsnet-library-preview\">productApr 15, 2026\\\r\n<strong>Meet tailscale-rs, our new Rust library preview</strong> \\\r\nAn early preview of tailscale-rs is out now, and we’d like your help testing what comes next.\\\r\n\\\r\n<img src=\"https://cdn.sanity.io/images/w77i7m8x/production/c1d565287da262060d5300fef5cbce3953d78617-1200x1197.jpg?w=3840&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"David Anderson\">\\\r\n\\\r\nDavid Anderson</a> <a href=\"https://tailscale.com/blog/pricing-v4\">productApr 08, 2026\\\r\n<strong>Pricing v4: more value, more simply</strong> \\\r\nTailscale’s updated plans are now live. The free Personal plan is more generous, business plans include more features, and pricing is easier to understand and predict.\\\r\n\\\r\n<img src=\"https://cdn.sanity.io/images/w77i7m8x/production/07510b1cfc4243485b30ed540b821e5c6a928beb-600x559.jpg?w=1200&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"Avery Pennarun Headshot\">\\\r\n\\\r\nAvery Pennarun</a> <a href=\"https://tailscale.com/blog/adult-in-the-room\">insightsApr 02, 2026\\\r\n<strong>Being the adult in the room</strong> \\\r\nThe industry is moving quickly. The hard part is not losing your footing.\\\r\n\\\r\n<img src=\"https://cdn.sanity.io/images/w77i7m8x/production/07510b1cfc4243485b30ed540b821e5c6a928beb-600x559.jpg?w=1200&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"Avery Pennarun Headshot\">\\\r\n\\\r\nAvery Pennarun</a></p>\n<h2>Subscribe to Tailscale’s blog</h2>\n<p>We have a deep commitment to keeping your data safe.</p>\n<p>Email*</p>\n<p>Too much email?<a href=\"https://tailscale.com/blog/index.xml\">RSS Feed</a> RSS<a href=\"https://twitter.com/tailscale\">Tailscale on twitter</a> Twitter</p>\n<h2>Try Tailscale for free</h2>\n<p><a href=\"https://login.tailscale.com/start\">Get started</a></p>\n<p>Schedule a demo</p>\n<p><a href=\"https://tailscale.com/contact/sales\">Contact sales</a></p>\n<p><img src=\"https://cdn.sanity.io/images/w77i7m8x/production/b715b4ca5e2577da60f0d529a4a9bc2ad4cadf59-362x567.svg?w=750&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"cta phone\"></p>\n<p><img src=\"https://cdn.sanity.io/images/w77i7m8x/production/459a7a8492910eeb22f22bb8d4c0f864b0bae25f-199x81.svg?w=640&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"mercury\"></p>\n<p><img src=\"https://cdn.sanity.io/images/w77i7m8x/production/7d127f4bb62a408b056328349f291857df6251b3-199x81.svg?w=640&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"instacrt\"></p>\n<p><img src=\"https://cdn.sanity.io/images/w77i7m8x/production/e9579b00087d7896e9cb750f4eb39f2c11ed11b8-199x82.svg?w=640&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"Retool\"></p>\n<p><img src=\"https://cdn.sanity.io/images/w77i7m8x/production/7958bf3d43a30e661ca74cf0510f250d9b99ecef-199x81.svg?w=640&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"duolingo\"></p>\n<p><img src=\"https://cdn.sanity.io/images/w77i7m8x/production/68e2e5024898bcd6f6d142e0306dc7564787e1d7-199x82.svg?w=640&#x26;q=75&#x26;fit=clip&#x26;auto=format\" alt=\"Hugging Face\"></p>\n"}