> cat /etc/docktail/complete-guide.md

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

  1. Hardened Socket Access: The socket-proxy container limits the Docker API scope, preventing any container from gaining root access to the host daemon.
  2. 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.
  3. Zero Public Exposure: Tools like it-tools, flare, and filebrowser remain safely hidden behind your Tailnet barrier while being dynamically managed via standard Docker labels.