Post

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.

Deploy a Samba root share over Headscale/Tailscale with Docker

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 445 only 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 = no is set in smb.conf;
  • force user matches 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:

This post is licensed under CC BY 4.0 by the author.