πŸͺ yoink

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:

  1. Operator β†’ host: SSH, paired with Tailscale for hostname + auth + ACLs.
  2. Service β†’ service: docker networks per yoink up host, plus services[].hosts to pin which boxes run what.
  3. 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. No ssh_config to 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:server group. 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-action joins the tailnet under tag:ci; ACL grants tag:ci β†’ tag:server SSH; 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: :22 accessible 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

concerntool
container lifecycle (pull, start, healthcheck, drain, replace)yoink
dep-ordered deploys (redis before api before caddy)yoink
per-tier network isolationyoink
HTTPS / hostname routing / cert issuanceyoink (bundled Caddy; see proxy guide)
operator β†’ host connectivityTailscale (or your SSH config)
CI β†’ host connectivityTailscale (or your SSH config)
stateful services (postgres, etc.)yoink (with a named volume) or docker compose on the host
secretsyoink (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 host

Singleton (cron, queue worker):

services:
  - name: scheduler
    hosts: [prod-eu-1]       # one host
    run: { replicas: 1 }     # one container β€” singleton

Stateful 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-1 cannot dial redis:6379 on prod-db-1 over 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 via publish: and have the consumer dial that tailnet hostname. Docker overlay networks would solve this generically; first-class support is on the roadmap.

Region-pinned: