# Host a private web server with Docker

Last validated Oct 27, 2026

## Introduction

Self-hosting a web application often requires configuring network access, TLS certificates, a reverse proxy, and authentication. [Tailscale Serve][docs-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][docs-magicdns] name, and your [access control rules][docs-access-control] 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](https://login.tailscale.com/start) 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][docs-install-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][docs-magicdns] and [HTTPS certificates][docs-enabling-https] 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][docs-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][xt-docker-install].

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][xt-docker-non-root].

> **Warning:**
>
> 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][xt-docker-rootless].

Confirm the installation:

```shell
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.

```shell
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:

```shell
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.

> **Note:**
>
> 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:

```shell
docker ps
```

A message similar to the following confirms that the container is running:

```shell
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:

```shell
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.

> **Tip:**
>
> 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:
>
> ```yaml
> 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:

```shell
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:

```shell
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:

```shell
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`:

```shell
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][docs-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`][docs-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`][docs-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][docs-access-control] 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][docs-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](/docs/reference/user-roles/) to edit a tailnet policy file.

### Create a group for your web server users

Tailscale [groups][docs-syntax-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.

1. Go to the [Visual editor](https://console.tailscale.com/admin/acls/visual) tab of the admin console.
2. Select **Groups**, and then select **Create group**.
3. For **Group name**, enter `web-users`.
4. For **Members**, select yourself from the dropdown list, along with anyone else who should reach the web server.
5. (Optional) For **Note**, enter any note you'd like to keep for this group.
6. Select **Save group**.

The visual editor writes the following to your tailnet policy file:

```json
"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][docs-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.

1. Go to the [Visual editor](https://console.tailscale.com/admin/acls/visual) tab of the admin console.

2. Select **Tags**, and then select **Create tag**.

3. For **Tag name**, enter `private-web`.

4. Leave **Tag owner** empty. With no tag owner defined, only [Owners, Admins, and Network admins](/docs/reference/user-roles/) can apply `tag:private-web`.

   > **Note:**
   >
   > 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-users` as a tag owner.

5. (Optional) For **Note**, enter any note you'd like to keep for this tag.

6. Select **Save tag**.

The visual editor writes the following to your tailnet policy file:

```json
"tagOwners": {
  "tag:private-web": [],
}
```

Apply the tag to your Docker host by running the following [`tailscale up`][docs-tailscale-up] command on it:

```shell
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](https://console.tailscale.com/admin/machines) page of the admin console.

> **Note:**
>
> 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`][docs-policy-syntax-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.

1. Go to the [Visual editor](https://console.tailscale.com/admin/acls/visual) tab of the admin console.
2. Select **General access rules**, and then select **Add rule**.
3. For **Source**, select `group:web-users`.
4. For **Destination**, select `tag:private-web`.
5. For **Port and protocol**, enter `443` and select `TCP`.
6. (Optional) For **Note**, enter any note you'd like to keep for this grant.
7. Select **Save grant**.

The visual editor writes the following to your tailnet policy file:

```json
"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.

> **Warning:**
>
> 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][docs-policy-tests] 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`:

```json
"tests": [
  {
    "src": "eve@example.com",
    "deny": ["tag:private-web:443"]
  }
]
```

Refer to [Grants][docs-grants] for the full grant syntax and [Access control][docs-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][docs-tags-key-expiry] for tagged devices. If you skipped the tag and the device is still user-owned, go to the [Machines](https://console.tailscale.com/admin/machines) page of the admin console, select the  icon next to the device, then select **Disable key expiry**.

> **Warning:**
>
> 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][docs-key-expiry].

## Turn off Serve

When you no longer want to share the application, turn off the proxy:

```shell
tailscale serve --https=443 off
```

To clear every Serve configuration on the device at once, run [tailscale serve reset][docs-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:

```shell
docker stop private-web
```

If you started the container by using `docker compose up`, run:

```shell
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][docs-tailscale-funnel].

For more ways to use Serve, refer to [Tailscale Serve examples][docs-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][docs-secure-server-ubuntu].

## 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`][xt-github-scaletail] repository on GitHub.

[docs-access-control]: /docs/features/access-control/acls

[docs-enabling-https]: /docs/how-to/set-up-https-certificates

[docs-grants]: /docs/features/access-control/grants

[docs-install-linux]: /docs/install/linux

[docs-key-expiry]: /docs/features/access-control/key-expiry

[docs-magicdns]: /docs/features/magicdns

[docs-policy-syntax-grants]: /docs/reference/syntax/policy-file#grants

[docs-policy-tests]: /docs/reference/syntax/policy-file#tests

[docs-secure-server-ubuntu]: /docs/how-to/secure-ubuntu-server-with-ufw

[docs-serve-examples]: /docs/reference/examples/serve

[docs-serve-reset]: /docs/reference/tailscale-cli/serve#reset-tailscale-serve

[docs-syntax-groups]: /docs/reference/syntax/policy-file#groups

[docs-tags-key-expiry]: /docs/features/tags#key-expiry

[docs-tags]: /docs/features/tags

[docs-tailnet-name]: /docs/concepts/tailnet-name

[docs-tailnet-policy-file]: /docs/features/tailnet-policy-file

[docs-tailscale-down]: /docs/reference/tailscale-cli#down

[docs-tailscale-funnel]: /docs/features/tailscale-funnel

[docs-tailscale-serve]: /docs/features/tailscale-serve

[docs-tailscale-up]: /docs/reference/tailscale-cli/up

[xt-docker-install]: https://docs.docker.com/engine/install/

[xt-docker-non-root]: https://docs.docker.com/engine/install/linux-postinstall/

[xt-docker-rootless]: https://docs.docker.com/engine/security/rootless/

[xt-github-scaletail]: https://github.com/tailscale-dev/ScaleTail
