TL;DR Use a named custom bridge for same-host service groups, an external
proxynetwork shared with Traefik for TLS termination, and a socket proxy to avoid bind-mounting the Docker socket directly into Traefik. Wire startup order withdepends_on: condition: service_healthyand keep tokens out of environment variables with the_FILEsecret convention. These five patterns compose cleanly for ntfy, Gotify, and Apprise stacks. [2] [3] [6]
Pattern 1 — Network driver selection
Docker ships seven drivers [1]. For a home lab notification stack, three matter:
| Driver | Use case | Home lab example |
|---|---|---|
bridge |
Default; single-host container groups; DNS by service name | Apprise + ntfy on the same host |
macvlan |
Container needs its own LAN IP/MAC; no NAT [11] | Zigbee2MQTT, VoIP adapters |
host |
Container binds directly to host ports; zero NAT overhead | IoT listener that must see LAN broadcasts |
overlay is Swarm-only; none is for maximum isolation. For everything else, bridge is correct.
Pattern 2 — Named custom networks (same-host service grouping)
Compose auto-creates a <project>_default bridge [2]. Services on it resolve each other by service name via Docker’s internal DNS at 127.0.0.11. Use a named network when you want explicit topology control:
# docker-compose.yml for your notification stack
networks:
notify-internal: # no public egress, containers talk by name
services:
ntfy:
image: binwiederhier/ntfy:latest
networks: [notify-internal]
apprise:
image: caronc/apprise:latest
networks: [notify-internal]
environment:
# Apprise reaches ntfy by service name — no host IP or port mapping needed
NTFY_URL: "http://ntfy:80"
Apprise → ntfy URL is http://ntfy:80/your-topic [9]. No published port is required for internal traffic. The service name ntfy resolves to the container’s IP on the shared network [12].
Pattern 3 — External proxy network for Traefik TLS termination
Traefik typically lives in its own Compose project. The standard pattern is one pre-created external network that Traefik and every back-end service both join [10]:
# Run once; must exist before any compose up
docker network create proxy
Traefik project (traefik/docker-compose.yml):
networks:
proxy:
external: true
services:
traefik:
image: traefik:v3
networks: [proxy]
ports: ["80:80", "443:443"]
Notification project (notify/docker-compose.yml):
networks:
notify-internal: # intra-stack; no external access
proxy:
external: true # joins the shared Traefik network
services:
ntfy:
image: binwiederhier/ntfy:latest
networks: [notify-internal, proxy] # bridges both networks
labels:
- traefik.enable=true
- traefik.http.routers.ntfy.rule=Host(`ntfy.home.example.com`)
- traefik.http.routers.ntfy.entrypoints=websecure
- traefik.http.routers.ntfy.tls.certresolver=letsencrypt
- traefik.http.services.ntfy.loadbalancer.server.port=80
- traefik.docker.network=proxy # required when container has >1 network
ntfy also needs behind-proxy: true in server.yml for correct IP extraction [13]. Apprise stays on notify-internal only — no need to expose it to the proxy layer.
Pattern 4 — Socket proxy (security hardening for Traefik)
Bind-mounting /var/run/docker.sock directly into Traefik is root-equivalent access to the host [6]. Replace it with Tecnativa/docker-socket-proxy ⭐ 6.1k, an HAProxy-based filter that returns 403 for disallowed endpoints.
# traefik/docker-compose.yml (extended)
networks:
socket-net: # isolated; only socket-proxy + traefik
proxy:
external: true
services:
socket-proxy:
image: tecnativa/docker-socket-proxy
restart: unless-stopped
networks: [socket-net]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
CONTAINERS: 1 # Traefik only needs read access to containers
NETWORKS: 1
# EVERYTHING ELSE defaults to 0 (denied)
traefik:
image: traefik:v3
restart: unless-stopped
networks: [socket-net, proxy]
command:
- --providers.docker=true
- --providers.docker.endpoint=tcp://socket-proxy:2375 # TCP, not socket mount
- --providers.docker.exposedbydefault=false
- --entrypoints.websecure.address=:443
ports: ["80:80", "443:443"]
depends_on: [socket-proxy]
socket-net is never exposed outside the host. socket-proxy never joins the proxy network — Traefik is the only service that talks to it [7].
Pattern 5 — service_healthy startup ordering
depends_on: started (the default) checks only that the container process launched, not that the service is accepting connections. Use service_healthy when one service must be fully ready before another starts [4]:
services:
ntfy:
image: binwiederhier/ntfy:latest
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/v1/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s # grace period before first check
apprise:
image: caronc/apprise:latest
depends_on:
ntfy:
condition: service_healthy # waits for ntfy's healthcheck to pass
restart: true # restart apprise if ntfy restarts
start_period prevents false-unhealthy on slow-starting containers. If ntfy never becomes healthy, Apprise queues indefinitely — set start_period generously for cold-start hardware [8].
Pattern 6 — Secrets and credentials
Two common wiring mistakes: hardcoding tokens in compose files, and confusing env_file: with .env [14]:
| Mechanism | Where it lands | Use for |
|---|---|---|
env_file: |
Inside the container’s environment | Non-sensitive config (TZ, ports) |
.env file |
Compose-file interpolation only | Build-time variables (${VERSION}) |
secrets: + _FILE |
/run/secrets/<name> (tmpfs mount) |
API tokens, passwords, webhook secrets |
secrets:
ntfy_token:
file: ./secrets/ntfy_token.txt
services:
apprise:
image: caronc/apprise:latest
secrets: [ntfy_token]
environment:
# Many images read <VAR>_FILE and load the secret from disk
NTFY_TOKEN_FILE: /run/secrets/ntfy_token
Secrets are mounted as an in-memory tmpfs, not persisted on disk in the container, and are not visible in docker inspect environment output [5]. For Gotify’s application token in Apprise, the URL pattern is gotify://host:port/TOKEN — keep the token in a secret, not inline in the Apprise config YAML [15].
Full reference: ntfy + Apprise + Traefik
Putting all patterns together in a single-file reference:
# notify/docker-compose.yml
networks:
notify-internal: {}
proxy:
external: true
secrets:
ntfy_token:
file: ./secrets/ntfy_token.txt
services:
ntfy:
image: binwiederhier/ntfy:latest
restart: unless-stopped
command: serve
networks: [notify-internal, proxy]
volumes:
- ntfy-cache:/var/cache/ntfy
- ntfy-auth:/var/lib/ntfy
- ./ntfy/server.yml:/etc/ntfy/server.yml:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/v1/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.ntfy.rule=Host(`ntfy.home.example.com`)
- traefik.http.routers.ntfy.entrypoints=websecure
- traefik.http.routers.ntfy.tls.certresolver=letsencrypt
- traefik.http.services.ntfy.loadbalancer.server.port=80
apprise:
image: caronc/apprise:latest
restart: unless-stopped
networks: [notify-internal] # internal only; Traefik not needed
secrets: [ntfy_token]
environment:
APPRISE_STATEFUL_MODE: simple
NTFY_URL: "http://ntfy:80" # DNS by service name; no host port required
depends_on:
ntfy:
condition: service_healthy
restart: true
volumes:
- ./apprise/config:/config
volumes:
ntfy-cache:
ntfy-auth:
Key decisions visible in this file:
ntfybridges both networks;apprisestays internal-only.traefik.docker.network=proxyis required because ntfy has two network attachments [3].- Token never appears in plaintext — consumed via secret mount.
- Healthcheck gates Apprise startup;
restart: truepropagates ntfy restarts downstream.