๐ Exposing Securely
This guide shows how to reach LibreFolio away from home without opening any port on your router, using Tailscale, a secure mesh VPN that is free for home use. The same steps work for any other service on your local network.
You need Step 0 plus the one level that fits you. Levels 3 and 4 also need the one-time Funnel setup on the console.
| Level | Best for | Tailscale on your phone/PC | Public HTTPS address |
|---|---|---|---|
| ๐ 1. Private VPN | Just you, with the quickest setup | Needed | No |
| ๐ฅ 2. Subnet router | Every device of your home LAN, not only LibreFolio | Needed | No |
| ๐ฅ 3. Funnel | A public address for LibreFolio, and installing it as an app | Not needed | Yes |
| ๐ฅ 4. Multi-Funnel with Docker | One public address per service, each in its own container | Not needed | Yes |
โญ Our recommendation: Level 4
Level 4 needs little more setup than Level 3, keeps every service isolated in its own container and gives each one its own public address. Levels 1โ3 are simpler alternatives, and they show the path that leads there.
Everywhere below, 6040 is LibreFolio's default port: if you set a different PORT in your .env, use that number instead. LibreFolio serves the web app, its API and the built-in documentation on this one port, so it is the only port to expose.
๐ Why not plain port forwarding?
The classic way is to open a port on your home router and point a dynamic DNS name (such as DuckDNS) at your public IP. It works, but:
- The whole internet can see it: anyone can scan your public IP and attack the open port.
- HTTPS is your job: you must run a reverse proxy (Nginx, Caddyโฆ) and keep its SSL certificates renewed.
- Without HTTPS, data travels in clear: your password and financial data can be intercepted on the way.
graph LR
User["๐ค External User<br>(Away from Home)"] --- Cloud["โ๏ธ Internet / Router (Public IP / DDNS?)"]
Cloud --- Server["๐ฅ๏ธ Local Server<br>(Port 6040)"]
Tailscale avoids all three: no router port is opened, the traffic between your devices is encrypted, and Funnel (Levels 3 and 4) adds HTTPS with certificates that Tailscale manages for you.
๐ Step 0: Install Tailscale on your devices
Tailscale is a mesh VPN built on the WireGuard protocol: your devices join a private network (your tailnet) and talk to each other through encrypted tunnels. It runs on Linux, macOS, Windows, iOS and Android, on a NAS or inside Docker, and its free Personal plan is enough for home use (see Tailscale pricing for the current limits).
Install it and log in on at least two devices: the server that runs LibreFolio and a client, such as your phone or laptop.
Run the official installation command on the server:
For more details, see the Generic Installation Guide.
Install the official app from the Mac App Store or use Homebrew:
For more details, see the Generic Installation Guide.
Download the official installer from the Tailscale portal and follow the login wizard.
For details, see the Windows Installation Guide.
Install the official application from the Google Play Store.
Install the official application from the Apple App Store.
๐ Keep the server connected โ disable key expiry
By default, Tailscale asks every device to log in again after 180 days. A server should not drop off your tailnet for that, so turn it off for the server:
- On the Machines page of the admin console, locate your server.
- Click the three dots (...) icon on the right of the device row.
- Select the Disable Key Expiry option.
๐ Level 1: Private point-to-point VPN
Best for you alone, with the quickest setup. Your phone or laptop reaches LibreFolio through your private tailnet, and nothing is exposed to the internet.
graph LR
Client["๐ค Client (VPN active)<br>(100.x.y.z)"] -->|Direct VPN Connection| Server["๐ฅ๏ธ Server (VPN active)<br>(100.a.b.c:6040)"]
subgraph LAN ["Local LAN Network"]
Server -->|Local access| LibreFolio["๐ LibreFolio (Local)"]
end
style LibreFolio fill:#d4edda,stroke:#28a745,stroke-width:2px;
โถ๏ธ Step 1: Check that LibreFolio answers on its port
Nothing to share: with Tailscale switched on on the server, LibreFolio's port 6040 is already
reachable from your tailnet at the server's Tailscale IP.
Prefer an HTTPS address inside your tailnet?
Run this on the server, then open https://<server-name>.your-tailnet.ts.net instead of the IP:
--bg keeps it running after you close the terminal; tailscale serve reset removes it. The
first time, Tailscale may ask you to enable HTTPS certificates for your tailnet. The address is
still private: only your tailnet devices can open it.
๐ฑ Step 2: Open LibreFolio from your device
With Tailscale switched on on your phone or PC, type the server's Tailscale IP (or its MagicDNS name) followed by the port in the browser, for example http://100.a.b.c:6040.
To check the result, see Checking Connection Security.
โ ๏ธ Limits: every device you connect from needs Tailscale switched on, and you reach only the server, not the rest of your home LAN (Level 2 adds that).
๐ฅ Level 2: Subnet router for your whole home LAN
Best for reaching every device at home, not only LibreFolio. The server becomes a subnet router: with Tailscale on, your client opens any local IP as if it were at home, for example http://192.168.1.2:6040 for LibreFolio.
graph LR
Client["๐ค Client (VPN active)<br>(100.x.y.z)"] -->|WireGuard Tunneling| Server["๐ฅ๏ธ Server (Subnet Router)<br>(100.a.b.c)"]
subgraph LAN ["Local LAN Network (192.168.1.0/24)"]
Server -->|Local forwarding| LibreFolio["๐ LibreFolio<br>(e.g. 192.168.1.2:6040)"]
Server -->|Local forwarding| OtherDevice["๐จ๏ธ Other Devices/Services<br>(e.g. 192.168.1.100)"]
end
style LibreFolio fill:#d4edda,stroke:#28a745,stroke-width:2px;
๐ Step 1: Enable subnet routing on the server
Enable IP forwarding at the kernel level:
echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf
Start advertising the subnet (replace the IP range with your local network, e.g., 192.168.1.0/24):
Use the Tailscale executable path to advertise the local subnet:
โ Step 2: Approve the route in the admin console
- Go to the Tailscale Admin Console.
- Click the three dots next to your server -> Edit route settings.
- Enable the advertised subnet.
A subnet router is part of your network's plumbing: if you skipped it, disable key expiry for the server now (tip at the end of Step 0).
โ ๏ธ Limits: the client still needs Tailscale switched on, you must know the local IPs of your devices, and inside your LAN the traffic travels as plain HTTP.
๐ Enabling Funnel and ACLs on the console
One-time setup, needed by Levels 3 and 4. It allows Funnel in the access control rules (ACLs) of your whole tailnet.
๐ Before you go public
With Funnel, anyone on the internet can open your LibreFolio login page. Create your own account first: the first account registered becomes the administrator. Sign-ups stay open after that (Enable Registration, enable_registration, is on by default): turn it off in the Global Settings if you do not want strangers to register (see Global Settings).
- Visit the Access Controls page in the Tailscale admin console.
- Click the Add node attribute button.
- Fill in the form:
- Targets: the nodes allowed to use Funnel. We suggest
tag:external_access(you will give this tag to the Docker containers of Level 4) orautogroup:member(all the devices registered under your personal account). - Attributes: enter
funnel. - Note: a few words on why the rule exists.
- IP Pools, App, Capability, etc.: not needed here; leave them empty or at their default values.
- Targets: the nodes allowed to use Funnel. We suggest

This rule is not an auth key: auth keys (Level 4) only register a new device or container in your tailnet.
๐ View the complete ACL JSON configuration to enable Funnel
If you prefer to edit the policy file directly, this working example enables Funnel for your own devices and for the containers tagged tag:external_access:
{
// Declaration of authorized tags
"tagOwners": {
"tag:external_access": ["autogroup:admin"]
},
// Standard access rules
"acls": [
// Allows all nodes in your private network to communicate
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
],
"ssh": [
{
"action": "check",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"]
}
],
// Enabling Funnel on specific nodes or tags
"nodeAttrs": [
{
"target": ["autogroup:member"],
"attr": ["funnel"]
},
{
"target": ["tag:external_access"],
"attr": ["funnel"]
}
]
}
๐ฅ Level 3: Public HTTPS address with Tailscale Funnel
Best for a public address, with no VPN on the client. Funnel publishes LibreFolio at a secure https://<server-name>.your-tailnet.ts.net address that anyone can open without installing Tailscale. HTTPS is also what lets you install LibreFolio as an app (PWA) on your phone.
Before you start: complete the one-time Funnel and ACL configuration on the console.
graph LR
User["๐ค User (No VPN)"] -->|HTTPS Request| Funnel["โ๏ธ Tailscale Funnel Ingress<br>(Tailscale Public Server)"]
Funnel -->|WireGuard Tunneling| Server["๐ฅ๏ธ Local Server (tailscaled)<br>(100.a.b.c)"]
subgraph LAN ["Local LAN Network"]
Server -->|Local forwarding| LibreFolio["๐ LibreFolio (Port 6040)"]
Server -.->|"<font color='red'><b>Cannot expose</b></font>"| Other["๐ Other Local Services (Different ports)"]
end
style LibreFolio fill:#d4edda,stroke:#28a745,stroke-width:2px;
style Other fill:#f8d7da,stroke:#dc3545,stroke-width:2px;
linkStyle 3 stroke:#dc3545,stroke-width:2px;
โถ๏ธ Step 1: Start the Funnel on the server
On the server, publish the local LibreFolio port:
Funnel serves it at https://<server-name>.your-tailnet.ts.net on port 443; --bg keeps it running
after you close the terminal, and tailscale funnel reset stops it. No auth key is needed here: the
server already joined your tailnet in Step 0.
Nothing to configure in LibreFolio either: Funnel sends X-Forwarded-Proto: https, so with the
default SESSION_COOKIE_SECURE=auto LibreFolio marks its
session cookie Secure. never is only for a proxy that
claims HTTPS to a browser on plain HTTP.
โ Step 2: Approve and wait for propagation
The first time, the terminal warns that Funnel is not yet allowed for this node and shows a link like this one:
Funnel is enabled, but the list of allowed nodes in the tailnet policy file does not include the one you are using.
To give access to this node you can edit the tailnet policy file, or visit:
https://login.tailscale.com/f/funnel?node=xxxxxx
- Open the link in your browser, log in to Tailscale and approve Funnel for this node.
- The terminal then shows your public URL.
- Wait a few minutes for the MagicDNS records to propagate before you open it from an outside network.
To check the result, see Checking Connection Security.
โ ๏ธ Limits: one machine has one public name, which every service you publish from it must share. Level 4 gives each service its own.
๐ฅ Level 4: Multi-Funnel with Docker sidecars
Best for Docker users who want one public address per service. Each service gets a small Tailscale container (a sidecar) that joins your tailnet as its own node, with its own https://<name>.your-tailnet.ts.net address. A startup script installs socat in the sidecar, and socat forwards the Funnel traffic to the static LAN IP of the service.
Before you start: complete the one-time Funnel and ACL configuration on the console.
๐งฐ What is socat?
socat (SOcket CAT) is a small command-line tool that relays data between two connections. Here it works as a mini forwarder: it listens on a port inside the Tailscale container and passes everything it receives to the real port of the service on your LAN.
Add one sidecar for every service you want to publish, on one host or several; the only limit is the number of tagged devices your Tailscale plan allows. In this example, two hosts run two sidecars each:
graph LR
User["๐ค External User"] -->|HTTPS| Funnel1["โ๏ธ Funnel 1<br>(librefolio.yourtailnet.ts.net)"]
User -->|HTTPS| Funnel2["โ๏ธ Funnel 2<br>(service1.yourtailnet.ts.net)"]
User -->|HTTPS| Funnel3["โ๏ธ Funnel 3<br>(service3.yourtailnet.ts.net)"]
User -->|HTTPS| Funnel4["โ๏ธ Funnel 4<br>(service4.yourtailnet.ts.net)"]
Funnel1 -->|WireGuard| TSC1["๐ณ Tailscale Container 1<br>(100.101.x.x)"]
Funnel2 -->|WireGuard| TSC2["๐ณ Tailscale Container 2<br>(100.102.x.x)"]
Funnel3 -->|WireGuard| TSC3["๐ณ Tailscale Container 3<br>(100.103.x.x)"]
Funnel4 -->|WireGuard| TSC4["๐ณ Tailscale Container 4<br>(100.104.x.x)"]
subgraph LAN ["Local LAN Network (192.168.1.0/24)"]
subgraph Host2 ["Server 2 (e.g. Mini PC - 192.168.1.10)"]
TSC3 -->|socat: TCP/8080| Service3["๐ Service 3<br>(192.168.1.10:8080)"]
TSC4 -->|socat: TCP/9000| Service4["๐ Service 4<br>(192.168.1.10:9000)"]
end
subgraph Host1 ["Server 1 (e.g. NAS - 192.168.1.20)"]
TSC1 -->|socat: TCP/6040| LibreFolio["๐ LibreFolio<br>(192.168.1.20:6040)"]
TSC2 -->|socat: TCP/80| Service1["๐ Service 1<br>(192.168.1.20:80)"]
end
end
style LibreFolio fill:#d4edda,stroke:#28a745,stroke-width:2px;
๐ Step 1: Prepare the folder and the script
Create a folder on the server, for example where you keep your Docker persistent volumes:
# Create a folder for the Tailscale nodes and enter it
mkdir -p <path_chosen>/tailscale-nodes
cd <path_chosen>/tailscale-nodes
Then download the startup script custom_startup.sh into it:
# Download the script from the official repository
wget https://raw.githubusercontent.com/Librefolio/LibreFolio/main/mkdocs_src/docs/static/tailscale-guide/custom_startup.sh
# Make the script executable
chmod +x custom_startup.sh
๐ Set up the sidecar before? Update your copy of the script
The current script also works as a watchdog and comes with a Docker health check (see Step 2). If your sidecar runs an older copy, update it:
-
Download the script again into the same folder. The
-O custom_startup.shoption overwrites the old file (without it,wgetsaves the download ascustom_startup.sh.1). Then make sure the script is executable: -
Update your compose file: add
TS_ENABLE_HEALTH_CHECK,TS_LOCAL_ADDR_PORTand, optionally,STARTUP_TIMEOUTto the Tailscale service, together with thehealthcheckblock, exactly as in Step 2. -
Recreate the container: a restart does not apply compose changes. Run
docker compose up -din the folder of yourdocker-compose.yml(it recreates the services whose configuration changed), or use the Recreate / re-deploy action of Portainer or CasaOS. -
Check the log with
docker logs -f tailscale-librefolio: you should seeTailscale is running., thenStarting the funnel on port 6040...andAvailable on the internet:with your public URL. Within the 2-minutestart_periodof the health check, Docker shows the container as healthy (docker ps, Portainer, CasaOS). If it keeps restarting instead, see the troubleshooting panel in Step 3.
๐ณ Step 2: Configure Docker Compose
Add the Tailscale service to the same docker-compose.yml as the service it exposes (for example LibreFolio), so the two stay together:
services:
tailscale-librefolio:
image: tailscale/tailscale:latest
container_name: tailscale-librefolio
hostname: tailscale-librefolio
restart: unless-stopped
privileged: false
network_mode: bridge
cap_add:
- NET_ADMIN
- NET_RAW
devices:
- /dev/net/tun:/dev/net/tun
command:
- /custom_startup.sh
environment:
- HOST_IP=192.168.1.20 # Local IP of the service to expose (e.g. Server 1)
- HOST_PORT=6040 # Real port of the service to expose
- TAILSCALE_FUNNEL_PORT=6040 # Internal Funnel port
- TS_HOSTNAME=librefolio # Custom public hostname (e.g. librefolio)
- TS_AUTHKEY=tskey-auth-... # Authentication key generated by Tailscale
- TS_ACCEPT_DNS=true
- TS_STATE_DIR=/var/lib/tailscale
- TS_USERSPACE=false
- TS_ENABLE_HEALTH_CHECK=true # Expose /healthz for the healthcheck below (Tailscale โฅ 1.78)
- TS_LOCAL_ADDR_PORT=127.0.0.1:9002 # Where /healthz listens: inside the container only
- STARTUP_TIMEOUT=180 # Optional: seconds to reach the Running state (default 180)
volumes:
- <path_chosen>/tailscale-nodes/tailscale-librefolio/state:/var/lib/tailscale
- <path_chosen>/tailscale-nodes/custom_startup.sh:/custom_startup.sh
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
# Shows healthy/unhealthy in Docker, Portainer or CasaOS; the restart itself comes from custom_startup.sh exiting
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:9002/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 120s
Then set these values for your network:
| Value | What to put |
|---|---|
<path_chosen> |
The absolute path chosen in Step 1, where the script and the state data live (e.g. /home/user/docker). |
HOST_IP |
The static LAN IP of the machine that runs the service. |
HOST_PORT |
The real port of the service on that machine: for LibreFolio, the PORT of your .env (6040 by default). |
TAILSCALE_FUNNEL_PORT |
The port the container listens on and publishes through Funnel. Set it to the same value as HOST_PORT. |
TS_HOSTNAME |
The name of the node: the public address becomes https://TS_HOSTNAME.your-tailnet.ts.net. |
TS_AUTHKEY |
The auth key that registers the container in your tailnet (see below). |
To get the auth key for TS_AUTHKEY:
- Go to Tailscale Admin Settings Keys.
- Under the Auth keys section (not under the API access tokens section), click the Generate auth key... button.
- Turn on the Tags toggle and select your tag (e.g.
tag:external_access). Add a description you will recognize, such asdocker-librefolio-funnel. - Click Generate and copy the key (
tskey-auth-...).
Once the container has started, the one-time key is used up: it disappears from the Keys list, and the new device appears in Machines.
๐ฉบ Watchdog and health check โ what the extra settings do
The startup script also works as a watchdog, while the healthcheck block makes the state of the container visible:
- Watchdog (automatic restart): if Tailscale cannot start (for example, no internet connection at boot or a wrong flag), does not reach the Running state within
STARTUP_TIMEOUTseconds, or if Tailscale, socat or the Funnel stop later on, the script exits with an error and Docker restarts the container (restart: unless-stopped). The container never stays "running" with nothing behind it, and adocker stopstill shuts everything down cleanly. - Health check (status only):
TS_ENABLE_HEALTH_CHECK=trueturns on Tailscale's/healthzendpoint (Tailscale 1.78 or later), which answers200while the node has a Tailscale IP address and503otherwise. Docker queries it regularly and marks the container healthy or unhealthy; Portainer and CasaOS show the same status.
Plain Docker (outside Swarm mode) does not restart a container marked unhealthy: the restart comes from the script exiting, so you need no extra "autoheal" container (such helpers also need the Docker socket, which means full control of the host).
| Optional setting | What it does |
|---|---|
TS_LOCAL_ADDR_PORT |
Where /healthz listens. The endpoint needs no authentication, and Tailscale's default, [::]:9002, listens on all interfaces: 127.0.0.1:9002 keeps it inside the container. If you change it, update the URL of the healthcheck test too. |
STARTUP_TIMEOUT |
Seconds the script waits for the Running state (default 180) before it exits and Docker restarts the container. Raise it only on a very slow server. |
DEBUG |
Not in the example above. DEBUG=1 prints every command the script runs to the container log, for troubleshooting. Off by default to keep the log readable. |
๐ View the complete production Docker Compose file (LibreFolio + Tailscale)
A complete docker-compose.yml: the librefolio service of the official docker-compose.prod.yml, with the Tailscale sidecar next to it:
# =============================================================================
# LibreFolio โ Production Docker Compose
# =============================================================================
# Optimized for end-users running the official pre-built image from GHCR.
# =============================================================================
services:
librefolio:
image: ${LIBREFOLIO_IMAGE:-ghcr.io/librefolio/librefolio:latest}
container_name: librefolio
restart: unless-stopped
ports:
- "${PORT:-6040}:6040"
volumes:
- ./LibreFolio-data:/app/backend/data/prod-docker
env_file: .env
environment:
- LIBREFOLIO_DATA_DIR=/app/backend/data/prod-docker
- HOST=0.0.0.0
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:6040/api/v1/system/health')"]
interval: 30s
timeout: 10s
start_period: 15s
retries: 3
tailscale-librefolio:
image: tailscale/tailscale:latest
container_name: tailscale-librefolio
hostname: tailscale-librefolio
restart: unless-stopped
privileged: false
network_mode: bridge
cap_add:
- NET_ADMIN
- NET_RAW
devices:
- /dev/net/tun:/dev/net/tun
command:
- /custom_startup.sh
environment:
- HOST_IP=192.168.1.20 # Local IP of the service to expose (e.g. Server 1)
- HOST_PORT=6040 # Real port of the service to expose
- TAILSCALE_FUNNEL_PORT=6040 # Internal Funnel port
- TS_HOSTNAME=librefolio # Custom public hostname (e.g. librefolio)
- TS_AUTHKEY=tskey-auth-... # Replace with your generated key
- TS_ACCEPT_DNS=true
- TS_STATE_DIR=/var/lib/tailscale
- TS_USERSPACE=false
- TS_ENABLE_HEALTH_CHECK=true # Expose /healthz for the healthcheck below (Tailscale โฅ 1.78)
- TS_LOCAL_ADDR_PORT=127.0.0.1:9002 # Where /healthz listens: inside the container only
- STARTUP_TIMEOUT=180 # Optional: seconds to reach the Running state (default 180)
volumes:
- /DATA/AppData/tailscale-nodes/tailscale-librefolio/state:/var/lib/tailscale
- /DATA/AppData/tailscale-nodes/custom_startup.sh:/custom_startup.sh
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
# Shows healthy/unhealthy in Docker, Portainer or CasaOS; the restart itself comes from custom_startup.sh exiting
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:9002/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 120s
๐ Step 3: Start and approve the Funnel
Start the stack, the service and its Tailscale sidecar together:
Then follow the log of the Tailscale container:
On the first start, the log shows the approval link for the new node:
Funnel is enabled, but the list of allowed nodes in the tailnet policy file does not include the one you are using.
To give access to this node you can edit the tailnet policy file, or visit:
https://login.tailscale.com/f/funnel?node=nsKGo6k9ZF11CNTRL
- Open the link in your browser, log in to Tailscale and approve the Funnel activation.
- Right after approval, the log confirms the public URL and the local proxy:
Available on the internet:
https://librefolio.yourtailnet.ts.net/
|-- proxy http://127.0.0.1:6040
Press Ctrl+C to exit.
The service is now online: wait a few minutes for the MagicDNS records to propagate, then open the URL.
Nothing to configure in LibreFolio: Funnel sends X-Forwarded-Proto: https and socat passes it on,
so with the default SESSION_COOKIE_SECURE=auto LibreFolio marks its
session cookie Secure. never is only for a proxy that
claims HTTPS to a browser on plain HTTP.
To check the result, see Checking Connection Security.
๐ ๏ธ The container restarts in a loop or is marked unhealthy
A persistent problem shows up as a container that keeps restarting (Docker, Portainer or CasaOS may also mark it unhealthy), not as one that looks "running" but does not work. Read the log with docker logs tailscale-librefolio: before each restart, the script prints what went wrong, usually followed by Exiting so Docker restarts the container. The most common causes:
- A flag in
TS_EXTRA_ARGShas no value. This optional variable (not in the compose above) passes extra flags totailscale up, split on spaces. A flag without its value makestailscale upfail: the reason is on the line right afterRunning 'tailscale up', above the help text that starts withUSAGE(for exampleflag needs an argument: -advertise-tags), followed byfailed to auth tailscale: โฆ tailscale up failed: exit status 2andcontainerboot exited before Tailscale was running.Write each value as--flag=valueor--flag value:--advertise-tags=tag:containerand--advertise-tags tag:containerboth work. - A management panel cut the value. The environment-variable editor of CasaOS (and of similar panels) truncates a value at its second
=:TS_EXTRA_ARGS=--advertise-tags=tag:containerbecomes--advertise-tags, which fails as described above. In these panels, write flag values with a space:--advertise-tags tag:container. - A required variable is missing: the script stops at once with
HOST_IP is not set(or the same message forHOST_PORTorTAILSCALE_FUNNEL_PORT). - No internet connection at boot: the container keeps restarting until Tailscale can start, then works normally. This is expected.
- Very slow start: the log shows
Tailscale is not running after 180s.; increaseSTARTUP_TIMEOUT. - Unhealthy, but not restarting: the health check gets no successful answer from
/healthz. If you have just added it, make sure thatTS_ENABLE_HEALTH_CHECK=trueis set and that the URL of thehealthchecktest matchesTS_LOCAL_ADDR_PORT; otherwise, the node has no Tailscale IP address at the moment.
To trace every command of the script, add DEBUG=1 to the environment section, recreate the container and read the log again.
Because its auth key carries a tag, Tailscale disables key expiry for the container by default. With an untagged key, disable it as for the server (tip at the end of Step 0).
โ ๏ธ Limits: it takes a terminal and some editing of Docker Compose files.
๐ฎ MagicDNS and custom domains
MagicDNS gives each device of your tailnet a name: instead of an IP like 100.110.x.x, you can type http://your-server in the browser. Public Funnel addresses end in .ts.net (for example, https://librefolio.your-tailnet.ts.net, where librefolio is the TS_HOSTNAME of Level 4).
Prefer your own domain, such as librefolio.mydomain.com? Two methods work for private access, through the VPN:
๐ Method 1 โ A public DNS record pointing to the Tailscale IP (simplest)
- Log into your domain registrar's console (e.g., Cloudflare, GoDaddy, Namecheap).
- Create a type A (or AAAA for IPv6) DNS record for the chosen subdomain (e.g.,
librefolio.mydomain.com). - Point the record directly to the private Tailscale IP of your server (e.g.,
100.77.x.x).
Addresses in the 100.64.0.0/10 network are not routable on the internet, so the name works only while you are connected to your tailnet: no outsider can reach or scan the service. For details, see the Official documentation on DNS settings.
๐งญ Method 2 โ Split DNS with your own DNS server
For internal records that you manage yourself and never publish on the internet:
- Configure a private DNS server in your LAN (such as Pi-hole, AdGuard Home, or CoreDNS).
- Add local records of your domain pointing them to your Tailscale IPs.
- In the Tailscale admin console, go to DNS -> Nameservers -> Add Nameserver and add the Tailscale IP of your private DNS as a global nameserver or restricted to your domain. For details, see the Official documentation on Split DNS.
For public access, keep the *.ts.net address: Funnel serves it with a certificate signed for that name, so pointing your own domain at it (CNAME) causes SSL/TLS errors in browsers, unless you add your own reverse proxy (such as Caddy or Nginx) with certificates for your domain.