Exposing homelab services securely without opening inbound ports on your home router is the holy grail of self-hosting. While a public reverse proxy like Traefik handles external domain traffic, internal administrative tools, file browsers, and dashboards should remain strictly off the public web.
Docktail solves this by bridging Docker container workloads directly into a private Tailscale mesh network using container labels and a secure Docker socket proxy.
Here is a complete, production-ready implementation featuring the exact compose.yaml and Tailscale ACL policies I use to manage my internal homelab services.
01. Tailscale Access Control List (acl-services.json)
Before deploying the containers, configure your Tailscale Admin Console with the necessary ACLs. This allows your docktail orchestrator to automatically register and approve sidecar services on your tailnet securely.
{
"tagOwners": {
"tag:docktail-host": ["autogroup:admin"],
"tag:docktail-service": ["tag:docktail-host"]
},
"autoApprovers": {
"services": {
"tag:docktail-service": ["tag:docktail-host"]
}
},
"acls": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["tag:docktail-service: *"]
}
]
}tag:docktail-host: Owned exclusively by admins (autogroup:admin), restricting root orchestration control.tag:docktail-service: Owned by the host tag, allowing automatic registration of ephemeral workloads without manual UI intervention.
02. The Full Stack Manifest (compose.yaml)
This integrated manifest deploys Docktail, a hardened Socket Proxy, Traefik, Authelia SSO, and a suite of secure internal tools (it-tools, dockhand, flare, pwgen, picoshare, and filebrowser) flagged for Tailnet routing.
services:
docktail:
image: ghcr.io/marvinvr/docktail:latest
container_name: docktail-socketproxy
depends_on:
- socket-proxy
environment:
- DOCKER_HOST=tcp://socket-proxy:2375
- TAILSCALE_OAUTH_CLIENT_ID=${TAILSCALE_OAUTH_CLIENT_ID}
- TAILSCALE_OAUTH_CLIENT_SECRET=${TAILSCALE_OAUTH_CLIENT_SECRET}
- DEFAULT_SERVICE_TAGS=tag:docktail-service
volumes:
- /var/run/tailscale:/var/run/tailscale
networks:
- default
- docker-api
restart: unless-stopped
it-tools:
image: ghcr.io/corentinth/it-tools:latest
container_name: socketproxy-it-tools
labels:
- docktail.service.enable=true
- docktail.service.name=tools
- docktail.service.port=80
- docktail.service.service-port=443
restart: unless-stopped
dockhand:
image: fnsys/dockhand:latest
container_name: dockhand
restart: unless-stopped
labels:
- docktail.service.enable=true
- docktail.service.name=dockhand
- docktail.service.port=3000
- docktail.service.service-port=443
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- dockhand_data:/app/data
flare:
image: soulteary/flare
restart: always
command: flare
container_name: socketproxy-flare
environment:
- FLARE_DISABLE_LOGIN=0
- FLARE_USER=flare
- FLARE_PASS=akwasi123
labels:
- docktail.service.enable=true
- docktail.service.name=flare
- docktail.service.port=5005
- docktail.service.service-port=443
volumes:
- ./app:/app
pwgen:
container_name: pwgen
image: jocxfin/pwgen:latest
environment:
- NO_API_CHECK=true
restart: unless-stopped
labels:
- docktail.service.enable=true
- docktail.service.name=pwgen
- docktail.service.port=5069
- docktail.service.service-port=443
picoshare:
image: mtlynch/picoshare
environment:
- PS_SHARED_SECRET=dummypass
command: -db /data/store.db
container_name: socketproxy-picoshare
labels:
- docktail.service.enable=true
- docktail.service.name=picoshare
- docktail.service.port=4001
- docktail.service.service-port=443
restart: unless-stopped
volumes:
- ./data:/data
- picoshare_data:/data
# ========================
# Traefik Reverse Proxy
# ========================
traefik:
container_name: ${SERVICE_NAME}
image: ${DOCKER_IMAGE}
restart: always
ports:
- target: ${SERVICE_HTTP_PORT}
published: ${SERVICE_HTTP_PORT}
protocol: tcp
mode: host
- target: ${SERVICE_HTTPS_PORT}
published: ${SERVICE_HTTPS_PORT}
protocol: tcp
mode: host
- target: ${SERVICE_HTTPS_PORT}
published: ${SERVICE_HTTPS_PORT}
protocol: udp
mode: host
command:
- "--global.sendanonymoususage=false"
- "--global.checknewversion=false"
- "--entrypoints.http.address=:${SERVICE_HTTP_PORT}"
- "--entrypoints.https.address=:${SERVICE_HTTPS_PORT}"
- "--entryPoints.https.asDefault=true"
- "--entryPoints.https.http3"
- "--entryPoints.https.http3.advertisedport=${SERVICE_HTTPS_PORT}"
- "--entrypoints.tor.address=:8081"
- "--entryPoints.http.forwardedHeaders.trustedIPs=127.0.0.1/32,172.18.0.1/24,100.64.0.0/10,10.0.0.0/24"
- "--entryPoints.https.forwardedHeaders.trustedIPs=127.0.0.1/32,172.18.0.1/24,100.64.0.0/10,10.0.0.0/24"
- "--api=true"
- "--api.dashboard=true"
- "--ping=true"
- "--log.level=INFO"
- "--log.maxsize=100"
- "--log.format=common"
- "--accesslog=false"
- "--providers.docker=true"
- "--providers.docker.watch=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.endpoint=unix:///var/run/docker.sock"
- "--providers.docker.useBindPortIP=false"
- "--providers.docker.network=traefik"
- "--providers.file=true"
- "--providers.file.watch=true"
- "--providers.file.directory=/etc/traefik/config"
- "--providers.file.debugloggeneratedtemplate=true"
- "--certificatesresolvers.le.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.le.acme.storage=/data/ssl/acme.json"
- "--certificatesresolvers.le.acme.dnsChallenge.resolvers=1.1.1.1:53,8.8.8.8:53"
- "--certificatesresolvers.le.acme.dnsChallenge.provider=${ACME_PROVIDER}"
- "--certificatesresolvers.le.acme.dnsChallenge.propagation.delayBeforeChecks=30"
environment:
- TZ=Asia/Shanghai
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.routers.traefik-dashboard-secure.tls.certresolver=le"
- "traefik.http.routers.traefik-dashboard-secure.tls.domains[0].main=${DNS_MAIN}"
- "traefik.http.routers.traefik-dashboard-secure.tls.domains[0].sans=${DNS_LIST}"
- "traefik.http.routers.traefik-dashboard-secure.tls=true"
- "traefik.http.routers.traefik-dashboard-secure.entrypoints=https"
- "traefik.http.routers.traefik-dashboard-secure.middlewares=gzip@file"
- "traefik.http.routers.traefik-dashboard-secure.rule=Host(`${SERVICE_DOMAIN}`)"
- "traefik.http.routers.traefik-dashboard-secure.service=dashboard@internal"
- "traefik.http.routers.traefik-dashboard-nosecure.entrypoints=http"
- "traefik.http.routers.traefik-dashboard-nosecure.middlewares=redir-https@file"
- "traefik.http.routers.traefik-dashboard-nosecure.rule=Host(`${SERVICE_DOMAIN}`)"
- "traefik.http.routers.traefik-dashboard-nosecure.service=noop@internal"
- "traefik.http.routers.traefik-dashboard-api.tls=true"
- "traefik.http.routers.traefik-dashboard-api.entrypoints=https"
- "traefik.http.routers.traefik-dashboard-api.middlewares=gzip@file"
- "traefik.http.routers.traefik-dashboard-api.rule=Host(`${SERVICE_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
- "traefik.http.routers.traefik-dashboard-api.service=api@internal"
- "docktail.service.enable=true"
- "docktail.service.name=traefik"
- "docktail.service.port=${SERVICE_HTTP_PORT}"
- "docktail.service.service-port=${SERVICE_HTTPS_PORT}"
- "docktail.service.network=traefik"
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config/:/etc/traefik/config/:ro
- ./ssl/:/data/ssl/
extra_hosts:
- "update.traefik.io:127.0.0.1"
- "collect.traefik.io:127.0.0.1"
- "stats.g.doubleclick.net:127.0.0.1"
- "${SERVICE_DOMAIN}:127.0.0.1"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider --proxy off localhost:8080/ping || exit 1"]
interval: 3s
retries: 10
logging:
driver: "json-file"
options:
max-size: "1m"
# ========================
# Authelia SSO
# ========================
authelia:
image: authelia/authelia
container_name: authelia
volumes:
- /home/nana/docker/authelia/config:/config
networks:
- traefik
security_opt:
- no-new-privileges:true
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.authelia.rule=Host(`auth.linuxpad.blog`)'
- 'traefik.http.routers.authelia.entrypoints=https'
- 'traefik.http.routers.authelia.tls=true'
- 'traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/verify?rd=https://auth.linuxpad.blog'
- 'traefik.http.middlewares.authelia.forwardAuth.trustForwardHeader=true'
- 'traefik.http.middlewares.authelia.forwardAuth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email'
- 'traefik.http.middlewares.authelia.forwardAuth.maxResponseBodySize=10485760'
- 'traefik.http.middlewares.authelia-basic.forwardAuth.address=http://authelia:9091/api/verify?auth=basic'
- 'traefik.http.middlewares.authelia-basic.forwardAuth.trustForwardHeader=true'
- 'traefik.http.middlewares.authelia-basic.forwardAuth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email'
- 'traefik.http.services.authelia.loadbalancer.server.port=9091'
ports:
- 9091:9091
restart: unless-stopped
environment:
- TZ=Europe/London
healthcheck:
disable: true
# ========================
# Redis (for Authelia)
# ========================
redis:
image: redis:alpine
container_name: redis
volumes:
- /home/nana/docker/authelia/redis:/data
networks:
- traefik
expose:
- 6379
restart: unless-stopped
environment:
- TZ=Europe/London
# ========================
# Docker Socket Proxy
# ========================
socket-proxy:
image: lscr.io/linuxserver/socket-proxy:latest
container_name: socket-proxy
environment:
- ALLOW_START=1
- ALLOW_STOP=1
- ALLOW_RESTARTS=1
- AUTH=1
- BUILD=1
- COMMIT=1
- CONFIGS=1
- CONTAINERS=1
- DISABLE_IPV6=1
- DISTRIBUTION=1
- EVENTS=1
- EXEC=1
- IMAGES=1
- INFO=1
- LOG_LEVEL=info
- NETWORKS=1
- NODES=1
- PING=1
- PLUGINS=1
- POST=1
- SECRETS=1
- SERVICES=1
- SESSION=1
- SWARM=1
- SYSTEM=1
- TASKS=1
- TZ=Etc/UTC
- VERSION=1
- VOLUMES=1
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- traefik
- docker-api
read_only: true
tmpfs:
- /run
restart: unless-stopped
filebrowser:
image: filebrowser/filebrowser:s6
container_name: socketproxy-filebrowser
labels:
- docktail.service.enable=true
- docktail.service.name=files
- docktail.service.port=80
- docktail.service.service-port=443
volumes:
- ./files:/srv
- filebrowser-db:/database
- filebrowser-config:/config
restart: unless-stopped
networks:
docker-api:
internal: true
traefik:
external: true
volumes:
filebrowser-db:
filebrowser-config:
dockhand_data:
picoshare_data:03. How It Works Together
- Hardened Socket Access: The
socket-proxycontainer limits the Docker API scope, preventing any container from gaining root access to the host daemon. - Dynamic Tailnet Ingress: Docktail listens for containers tagged with
docktail.service.enable=true, automatically spinning up sidecars and advertising them to your Tailscale mesh network. - Zero Public Exposure: Tools like
it-tools,flare, andfilebrowserremain safely hidden behind your Tailnet barrier while being dynamically managed via standard Docker labels.