Deploy a Samba root share over Headscale/Tailscale with Docker
Expose an administrative SMB share only on a Headscale/Tailscale address, with Docker binding, explicit Samba config, and strong safety warnings.
Samba provides SMB/CIFS file sharing for Windows, macOS, and Linux clients. In a homelab, it is useful for quick administrative file access, backups, media folders, and emergency repairs.
This guide documents a tailnet-only Samba deployment where Docker publishes SMB only on a Headscale/Tailscale address.
1
2
3
4
5
6
7
Windows/macOS/Linux client on tailnet
↓ SMB :445
<server-tailnet-ip>
↓
Samba container
↓
Host path mounted into the container
This post includes an administrative root-share pattern because it is useful for private maintenance. It is dangerous if exposed outside the tailnet. All IPs, usernames, passwords, and hostnames are placeholders. Never publish a real SMB password or bind this pattern to a public interface.
What this service does
The service exposes one SMB share over a private network:
1
\\<server-tailnet-ip>\root
Access rules:
1
2
3
4
5
Allowed network: Headscale/Tailscale only
Bind address: <server-tailnet-ip>
Public internet: no
LAN wildcard bind: no
SMB port: 445/tcp
Use cases:
- emergency file edits from a Windows machine;
- moving files into Docker service folders;
- inspecting logs or configuration without SSH file transfer;
- one-admin homelab maintenance.
Avoid using this pattern for multi-user file sharing. For normal users, create narrow shares with limited directories and non-root accounts.
Architecture
1
2
3
4
5
6
7
8
9
10
11
Tailnet client
↓
SMB mount: \\<server-tailnet-ip>\root
↓
Docker published port: <server-tailnet-ip>:445
↓
Samba container
↓
Container path: /share
↓
Host path: /
The important security control is the Docker port binding:
1
2
ports:
- "<server-tailnet-ip>:445:445/tcp"
Do not use this for an administrative share:
1
2
ports:
- "445:445"
That binds to every host interface and can expose SMB to networks you did not intend.
Folder layout
1
2
3
4
5
/home/ubuntu/samba/
├── docker-compose.yml
├── .env
└── config/
└── smb.conf
Protect the environment file:
1
chmod 600 /home/ubuntu/samba/.env
Docker Compose
This Compose file binds SMB only to the tailnet address.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
services:
samba-root:
build: .
container_name: samba-root-tailnet
restart: unless-stopped
env_file: .env
environment:
SAMBA_USER: ${SAMBA_USER}
SAMBA_PASSWORD: ${SAMBA_PASSWORD}
ports:
- "<server-tailnet-ip>:445:445/tcp"
volumes:
- /:/share
- ./config/smb.conf:/etc/samba/smb.conf:ro
healthcheck:
test: ["CMD-SHELL", "smbclient -L localhost -U ${SAMBA_USER}%${SAMBA_PASSWORD} >/dev/null 2>&1"]
interval: 60s
timeout: 10s
retries: 5
start_period: 30s
Example .env:
SAMBA_USER=root
SAMBA_PASSWORD=<strong-private-password>
If you use a public image that already handles Samba users, you may not need a custom build. If you use your own image, make sure the entrypoint creates the Samba user from the environment and starts smbd in the foreground.
Samba configuration
A minimal administrative share:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[global]
server role = standalone server
workgroup = WORKGROUP
security = user
map to guest = never
bind interfaces only = yes
interfaces = lo eth0
server min protocol = SMB2
smb ports = 445
log file = /var/log/samba/log.%m
max log size = 1000
[root]
path = /share
browseable = yes
read only = no
guest ok = no
valid users = root
force user = root
force group = root
admin users = root
create mask = 0644
directory mask = 0755
follow symlinks = yes
wide links = yes
unix extensions = no
The share maps the container path /share to the host path mounted by Docker.
For safer everyday use, replace / with a narrower directory:
1
2
volumes:
- /home/ubuntu/shared:/share
Then change the share name from root to something like shared.
Start the service
1
2
cd /home/ubuntu/samba
docker compose up -d
Verify the published socket:
1
ss -lntup | grep ':445'
Expected pattern:
1
<server-tailnet-ip>:445
Not:
1
0.0.0.0:445
Mount from Windows
From a Windows machine connected to the tailnet:
1
net use Z: \\<server-tailnet-ip>\root /user:root <password>
Remove the mapping:
1
net use Z: /delete
If Windows cached old credentials:
1
2
cmdkey /list
cmdkey /delete:<server-tailnet-ip>
Then reconnect.
Mount from Linux
Install CIFS utilities:
1
sudo apt-get install cifs-utils
Mount manually:
1
2
3
sudo mkdir -p /mnt/server-root
sudo mount -t cifs //<server-tailnet-ip>/root /mnt/server-root \
-o username=root,password='<password>',vers=3.0,uid=$(id -u),gid=$(id -g)
Unmount:
1
sudo umount /mnt/server-root
Password changes and container recreation
Docker Compose reads .env at container creation time. If the Samba password is passed through environment variables, a simple restart may not apply the new value.
After editing .env, recreate the container:
1
2
cd /home/ubuntu/samba
docker compose up -d --force-recreate samba-root
Then test from a client again.
Verification
List shares from the server:
1
docker exec samba-root-tailnet smbclient -L localhost -U root%'<password>'
Check Docker port binding:
1
docker port samba-root-tailnet 445/tcp
Expected:
1
<server-tailnet-ip>:445->445/tcp
Test from a tailnet client:
1
smbclient //<server-tailnet-ip>/root -U root
Try creating a harmless test file:
1
2
put test.txt tmp/samba-test.txt
rm tmp/samba-test.txt
Troubleshooting
Login still uses the old password
Recreate the container, not just restart it:
1
docker compose up -d --force-recreate samba-root
Environment values are baked into the container at creation time.
Windows says multiple connections are not allowed
Windows does not like connecting to the same server with different credentials at the same time.
1
2
3
4
net use
net use \\<server-tailnet-ip>\IPC$ /delete
net use Z: /delete
cmdkey /delete:<server-tailnet-ip>
Then reconnect.
Connection times out
Check:
- the client is connected to Headscale/Tailscale;
- the server tailnet IP is correct;
- Docker published
445only on that IP; - the host firewall allows TCP 445 on the tailnet interface;
- no other SMB service is already bound to port 445.
Share is visible but writes fail
Check:
- the container mounted the intended host path read/write;
read only = nois set insmb.conf;force usermatches a user that can write to the mounted path;- the host filesystem is not read-only.
Security notes
- Bind SMB to the tailnet IP only.
- Do not expose TCP 445 publicly.
- Use a strong password even on a private tailnet.
- Prefer narrow shares over
/for routine use. - Root/admin shares are for trusted operators only.
- Audit Docker volume mounts before starting the service.
- Remove the share when you no longer need broad maintenance access.
Service documentation map
This post connects to the rest of the homelab stack:
- Headscale control server for the private network used to reach SMB.
- Private Pi-hole DNS over Headscale for resolving private names to tailnet addresses.
- Portainer Docker management for inspecting the Samba container and bind mounts.
- Traefik reverse proxy for contrast: SMB is not an HTTP service and should not be routed through Traefik.
- Jekyll documentation site for publishing service guides like this one.