Host a private web server with Docker
Introduction
Self-hosting a web application often requires configuring network access, TLS certificates, a reverse proxy, and authentication. Tailscale Serve can handle network access and HTTPS while keeping the application available within your Tailscale network (known as a tailnet). Serve uses a valid certificate for the host's MagicDNS name, and your access control rules decide who can connect. You don't need to expose the Docker-published port to your LAN or the public internet.
In this guide, you'll set up Docker on a Linux host, run a containerized web application with its published port bound to the host's loopback interface, and use Serve to provide access over HTTPS. When you finish, your application will be reachable from your tailnet with a valid certificate, and you'll have the option to restrict access to a specific group of users.
While this guide was tested on Linux 24.04, both Docker and Tailscale Serve work on other operating systems as well as other Linux versions.
Prerequisites
Before you begin this guide, you'll need the following:
- A Tailscale account. Create a free Personal account if you don't have one already.
- A Linux device to host the web server.
- The Tailscale client installed on that device and joined to your tailnet. For more information, refer to Install Tailscale on Linux.
- The ability to run Docker on the device. This guide assumes Ubuntu 24.04. Other distributions should work as long as you can install Docker 28.0.0 or later on them. Check your Docker version by running
docker version. - MagicDNS and HTTPS certificates enabled for your tailnet, so Serve can issue a certificate for your device.
- A second device in your tailnet with a web browser, to confirm the server is reachable from the tailnet.
- Permission to update your tailnet policy file if you plan to restrict access as described in optional Step 5.
Step 1: Install Docker
On the device that will host the web server, if you haven't already done so, install Docker Engine.
Depending on your Docker configuration, you might need to prefix the Docker commands in this guide with sudo. To run Docker commands without sudo, refer to the Docker topic Manage Docker as a non-root user.
Adding a user to the docker group grants root-level privileges on systems using the standard Docker daemon. For an alternative that reduces these privileges, refer to the Docker topic Docker rootless mode.
Confirm the installation:
docker run --rm hello-world
Docker pulls the hello-world image and prints a confirmation message. If the start of the message displays text similar to the following, Docker is working.
Hello from Docker!
This message shows that your installation appears to be working correctly.
Now that Docker is installed, you can run an application.
Step 2: Run a container bound to the host's loopback interface
Run the application you want to share. This guide uses the nginx image as a stand-in for your own application. Substitute whichever image you're hosting.
The important detail is the port mapping. Publish the container's port to the host's loopback interface (127.0.0.1) rather than to all interfaces:
docker run -d \
--name private-web \
--restart unless-stopped \
-p 127.0.0.1:8080:80 \
nginx
The -p 127.0.0.1:8080:80 flag maps port 80 inside the container to port 8080 on the host's loopback interface. Other devices can't connect directly to the published port, but Serve can reach the application locally and provide access over your tailnet.
If you publish the port as -p 8080:80 instead of the full -p 127.0.0.1:8080:80, Docker binds it to all host interfaces by default. Docker's firewall rules can also bypass rules configured with some operating system firewalls such as ufw on Ubuntu. Depending on your setup, this could allow traffic to port 8080 from the host's local network or the public internet. Keep the 127.0.0.1: prefix to limit access to the published port to the host.
Confirm the container is running:
docker ps
A message similar to the following confirms that the container is running:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
79e3fd4a378f nginx "/docker-entrypoint.…" About a minute ago Up About a minute 127.0.0.1:8080->80/tcp private-web
Confirm the container is responding on the host:
curl http://127.0.0.1:8080
If the curl command returns the nginx welcome page, or your own application, the container is ready to share.
If you prefer to manage the container using a configuration file, you can use Docker Compose. Create a compose.yaml file with the same local-only port binding:
services:
web:
image: nginx
container_name: private-web
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
Then start it with docker compose up -d. Use this as an alternative to the docker run command above.
Now that your application is running, share it to your tailnet.
Step 3: Share the container with Tailscale Serve
Put Tailscale Serve in front of the container. Serve handles HTTPS using a certificate for your device's MagicDNS name and proxies requests to the local port.
Run the following command on the Linux device:
tailscale serve --bg 8080
The --bg flag keeps Serve running after you close your terminal and resumes sharing automatically after the host reboots or Tailscale restarts.
Serve confirms the configuration and prints the URL for your server:
Available within your tailnet:
https://<machine-name>.<tailnet-name>.ts.net/
|-- proxy http://127.0.0.1:8080
Serve started and running in the background.
To disable the proxy, run: tailscale serve --https=443 off
If your tailnet isn't yet configured for Serve, the command prints a login server URL instead. Open it in a browser and follow the prompts to enable HTTPS certificates and Serve for your tailnet, then run the command again.
Check what the device is currently serving at any time:
tailscale serve status
(Optional) Serve a path instead of the root
If you want the application to appear under a path other than the root of the domain, pass the path with --set-path:
tailscale serve --bg --set-path=/app 8080
The application is then available at https://<machine-name>.<tailnet-name>.ts.net/app. This lets you share several containers from one device, each on its own path, by running the command once per container with a different local port and path.
Now that you're running a web server in your tailnet, access it from a different device.
Step 4: Access the server from your tailnet
On your second device, confirm Tailscale is running and connected to the same tailnet. Then open a browser and visit the URL that Serve printed:
https://<machine-name>.<tailnet-name>.ts.net
Replace <machine-name> with the name of the machine that is running Tailscale Serve and <tailnet-name> with your tailnet name.
If you set a path instead of the root when you called tailscale serve, append the path to the URL. For example, if you set the path /app, use the following URL:
https://<machine-name>.<tailnet-name>.ts.net/app
The nginx welcome page, or your own application, loads over HTTPS with a valid certificate, with no browser warning and no port number in the URL. The first load can take some time because of the TLS certificate request process on the first request. This happens only once. Subsequent certificate renewals are handled in the background.
To confirm the server really is private, disable Tailscale on the second device with the tailscale down command or through the client UI, and load the URL again. The request should fail because the second device can no longer reach the server over Tailscale. Re-enable Tailscale with tailscale up when you're done confirming that the server is private.
Now that you've verified you can reach the web server in your tailnet, further restrict who can access it.
Step 5: (Optional) Restrict who can reach the server
With the default policy that permits all tailnet traffic, Serve makes the application available to every device in your tailnet. Access control rules apply to Serve just like any other service, so if only part of your tailnet should reach the application, you can narrow access in your tailnet policy file.
You need to make the following updates to your tailnet policy file:
- Create a Tailscale group for the people who should reach the web server.
- Create a tag to identify the device hosting the web server, so the rule survives rebuilding or replacing the host.
- Create a grant that gives that group access to the tagged device on the port Serve listens on.
You need to be an Owner, Admin, or Network admin to edit a tailnet policy file.
You can use the visual policy editor to manage your tailnet policy file. Refer to the visual editor reference for guidance on using the visual editor.
Create a group for your web server users
Tailscale groups let you create groups of users, which you can use in access rules instead of listing each user explicitly. Create a group named web-users so that you can grant access to everyone who needs the application in one place.
- Go to the Visual editor tab of the admin console.
- Select Groups, and then select Create group.
- For Group name, enter
web-users. - For Members, select yourself from the dropdown list, along with anyone else who should reach the web server.
- (Optional) For Note, enter any note you'd like to keep for this group.
- Select Save group.
The visual editor writes the following to your tailnet policy file:
"groups": {
"group:web-users": ["alice@example.com", "bob@example.com"]
}
Now that you have the web-users group, you can create a tag for the device hosting the web server.
Create a tag for your web server device
Tailscale tags let you authenticate and identify non-user devices. A tag consists of an identifier and the users and groups that can apply the tag. Create a tag:private-web tag so you can write a rule that targets the web server by role instead of by device name. The rule then keeps working if you rebuild the host or move the container to a different device.
-
Go to the Visual editor tab of the admin console.
-
Select Tags, and then select Create tag.
-
For Tag name, enter
private-web. -
Leave Tag owner empty. With no tag owner defined, only Owners, Admins, and Network admins can apply
tag:private-web.Applying a tag is a separate permission from reaching the web server. To let the people who use the web server tag devices themselves, add
group:web-usersas a tag owner. -
(Optional) For Note, enter any note you'd like to keep for this tag.
-
Select Save tag.
The visual editor writes the following to your tailnet policy file:
"tagOwners": {
"tag:private-web": [],
}
Apply the tag to your Docker host by running the following tailscale up command on it:
tailscale up --advertise-tags=tag:private-web
The device re-authenticates with the tag as its identity. Confirm the tag appears next to the device on the Machines page of the admin console.
Tagging a device changes its identity from a user-owned device to a tagged device. If you tag a device you're currently logged into over Tailscale SSH or another tailnet service, make sure your grants still allow you to reach it before you disconnect.
Now that you have a tag, you can use it in a grant.
Grant access to the web server
Add a grants rule that gives group:web-users access to the tagged device. Serve handles HTTPS on port 443, so that's the only port the grant needs to allow.
- Go to the Visual editor tab of the admin console.
- Select General access rules, and then select Add rule.
- For Source, select
group:web-users. - For Destination, select
tag:private-web. - For Port and protocol, enter
443and selectTCP. - (Optional) For Note, enter any note you'd like to keep for this grant.
- Select Save grant.
The visual editor writes the following to your tailnet policy file:
"grants": [
{
"src": ["group:web-users"],
"dst": ["tag:private-web"],
"ip": ["tcp:443"]
}
]
This grant lets members of group:web-users connect to any device that has the tag tag:private-web on TCP port 443. If no other access rule provides access to this destination and port, users outside group:web-users cannot connect to the web server.
Grants and ACLs are additive: adding this grant does not override an existing rule that provides broader access. For example, a rule that permits every user to reach all destinations on all ports still provides access to this server. Remove or narrow any other rules that provide access to tag:private-web on TCP port 443 if you want the restriction to take effect.
Now that you've updated your tailnet policy file, you can confirm that the policy restricts access.
Confirm the restriction works
Test the grant from a device in your tailnet that belongs to a user who is not in group:web-users. Loading https://<machine-name>.<tailnet-name>.ts.net from that device should fail to connect, while the same URL still loads for members of the group.
To catch future policy changes that unintentionally grant access, add an access rule test that asserts that a user outside the group cannot reach tag:private-web on port 443. Tests run every time the policy file is saved. Unlike the browser check above, this test evaluates the policy rather than live connectivity.
Recall that the group group:web-users contains users alice@example.com and bob@example.com. The following test asserts that eve@example.com, a tailnet user outside the group, is denied access to tag:private-web:443:
"tests": [
{
"src": "eve@example.com",
"deny": ["tag:private-web:443"]
}
]
Refer to Grants for the full grant syntax and Access control for how Tailscale evaluates rules.
Manage key expiry for the web server
Tailscale device keys expire after a configurable period. When the key for a user-owned device expires, the device must reauthenticate before it can reconnect to the tailnet. For a server that should remain continuously reachable, this can cause an unexpected interruption.
If you tagged and authenticated the device in Step 5, you don't need to do anything: key expiry is disabled by default for tagged devices. If you skipped the tag and the device is still user-owned, go to the Machines page of the admin console, select the icon next to the device, then select Disable key expiry.
Disabling key expiry removes the periodic reauthentication requirement. If the device or its key is compromised, expiry will no longer limit how long that access can continue. Only disable expiry for trusted devices, and revoke the key immediately if the device is compromised, lost, or replaced. For more information, refer to Key expiry.
Turn off Serve
When you no longer want to share the application, turn off the proxy:
tailscale serve --https=443 off
To clear every Serve configuration on the device at once, run tailscale serve reset. This stops serving all ports at once.
Stop the container
Stopping the container itself is a separate step.
If you started the container by using docker run, run:
docker stop private-web
If you started the container by using docker compose up, run:
docker compose down
Conclusion
In this guide, you set up a web server using Docker and made it reachable over HTTPS from your tailnet. If you completed Step 5, access is restricted to a specific group of users.
The same pattern works for any containerized web application: bind the container's published port to 127.0.0.1, point Serve at that port, and use grants to restrict access to the intended users. To host several applications on one device, give each container a different local port and share them on separate paths with --set-path.
If you want to make one of these applications available publicly over the internet rather than only within your tailnet, use Tailscale Funnel.
For more ways to use Serve, refer to Tailscale Serve examples. To lock down the host itself so that all access arrives over Tailscale, read the guide on securing an Ubuntu server with ufw.
Further exploration
For information about a collection of ready-to-run Docker Compose stacks that connect self-hosted applications to a tailnet, refer to the tailscale-dev/ScaleTail repository on GitHub.