Networking
Tailscale and SSH connectivity, multi-host distribution via replicas and pinned hosts, and yoink pf for debugging without publishing ports.
How yoink reaches your hosts, how services reach each other across them, and how you debug a service without weakening the security posture. Three layers:
- Operator β host: SSH, paired with Tailscale for hostname + auth + ACLs.
- Service β service: docker networks per
yoink uphost, plusservices[].hoststo pin which boxes run what. - Operator β service (debugging):
yoink pf <service>tunnels to any container without publishing host ports.
Connectivity layer (Tailscale + SSH)
Yoink's transport is ssh://user@host: it opens an SSH tunnel and speaks the Docker Engine API over the remote daemon's Unix socket. Yoink doesn't try to solve "how do I reach my hosts"; it expects you to bring an off-the-shelf SSH connectivity layer.
Pairing this with Tailscale SSH is what we recommend, and what every example in these docs assumes:
- Hostnames work everywhere. MagicDNS gives every host a stable name (
my-server) reachable from your laptop, CI runner, anywhere on the tailnet. Nossh_configto maintain, no jump hosts, no bastion. - Auth without keys. Tailscale SSH issues short-lived certs based on tailnet membership and ACLs. Onboard a new operator: invite to the tailnet, grant ACL access to the
tag:servergroup. Done. Off-board: revoke from tailnet. Their key is gone everywhere, immediately. - CI authentication is the same flow. A GH Actions runner with
tailscale/github-actionjoins the tailnet undertag:ci; ACL grantstag:ci β tag:serverSSH; yoink connects without ever touching~/.ssh/. - No port forwarding. The Docker daemon never listens on a TCP port. The SSH transport handles auth + transport in one hop. Surface area:
:22accessible only from the tailnet.
The deploy user on each host is in the docker group (functionally root, scope your tailnet ACLs accordingly).
What yoink owns vs leaves to the connectivity layer
| concern | tool |
|---|---|
| container lifecycle (pull, start, healthcheck, drain, replace) | yoink |
| dep-ordered deploys (redis before api before caddy) | yoink |
| per-tier network isolation | yoink |
| HTTPS / hostname routing / cert issuance | yoink (bundled Caddy; see proxy guide) |
| operator β host connectivity | Tailscale (or your SSH config) |
| CI β host connectivity | Tailscale (or your SSH config) |
| stateful services (postgres, etc.) | yoink (with a named volume) or docker compose on the host |
| secrets | yoink (age-sealed) or any external CLI via provider: command; see secrets guide |
Multi-host distribution
How services spread across hosts when you scale beyond one box. Two knobs: replicas (per host) and services[].hosts (which hosts run a service).
Default: every service on every host
By default a service runs on every host in hosts:, with replicas copies per host. So:
hosts:
- { address: prod-eu-1, user: deploy }
- { address: prod-eu-2, user: deploy }
services:
- name: api
image: ghcr.io/you/api
run:
port: 8080
replicas: 2β¦gives you 4 api containers total: 2 on prod-eu-1, 2 on prod-eu-2. Rolling swap is per host; yoink keeps replicas - 1 alive on each host during the swap.
Pinning a service to specific hosts
services[].hosts: whitelists which hosts run that service. Strings match hosts[].address.
hosts:
- { address: prod-eu-1, user: deploy }
- { address: prod-eu-2, user: deploy }
- { address: prod-db-1, user: deploy } # beefier box, dedicated to stateful
services:
- name: api
image: ghcr.io/you/api
domain: api.example.com # bundled Caddy auto-injects on every host that runs api
hosts: [prod-eu-1, prod-eu-2] # public-facing tier; redis stays internal
run: { port: 8080, replicas: 2 }
- name: redis
image: redis
tag: 7-alpine
hosts: [prod-db-1] # pin to the db host only
run: { port: 6379 }Common shapes
Stateless app, scale horizontally:
hosts: [prod-eu-1, prod-eu-2, prod-eu-3]
services:
- name: api
run: { replicas: 2 } # 6 total β 2 per hostSingleton (cron, queue worker):
services:
- name: scheduler
hosts: [prod-eu-1] # one host
run: { replicas: 1 } # one container β singletonStateful pinned + stateless replicated (single-host shape; see caveat below for multi-host):
services:
- name: redis
hosts: [prod-db-1]
run: { replicas: 1 }
- name: api
hosts: [prod-db-1]
networks: [redis]
run: { replicas: 2 }Cross-host service-to-service traffic is not yet first-class. Docker bridge networks (yoink's default) are per-host, so an api on
prod-eu-1cannot dialredis:6379onprod-db-1over the docker network alone. Today's working patterns: pin the dialing service to the same host as its dependency (above), or expose the stateful service on a tailnet IP viapublish:and have the consumer dial that tailnet hostname. Docker overlay networks would solve this generically; first-class support is on the roadmap.
Region-pinned: