๐Ÿช yoink

Standalone (no-registry) deploys

Ship images directly to hosts over SSH with no registry โ€” layer-level dedup via the bundled unregistry transport.

Build and ship a service in one yoink up --build: no CI, no registry. Default transport is unregistry (layer-level dedup over SSH); tarball fallback handles air-gapped hosts. For the build-origin ร— distribution mental model, see Deploy modes.

The minimum config

Drop a yoink.yaml next to your Dockerfile:

hosts:
  - { address: my-server, user: deploy }   # tailnet hostname

services:
  - name: my-tool
    image: my-tool                  # bare name โ€” no registry prefix
    tag: dev
    build:
      context: .                    # `.` = same directory as yoink.yaml
    run:
      port: 8080
yoink up --build                    # build + ship + run, in one command

Edit, rerun, the new version rolls. Skips the GitHub Actions + container-registry setup when you don't need it.

For separate build and deploy steps (sharing the artifact across shells, or building on a beefy laptop and deploying from a thin runner), use the explicit two-command form:

yoink build my-tool                                # docker build โ†’ tag local image as my-tool:dev
yoink up --service my-tool                         # save+load to each host

How yoink chooses

A service with build: is shipped from the operator's local docker daemon over SSH. A service without build: is pulled by each host from the registry in its image: ref. Per-service, no flag.

The local-shipping path uses the unregistry transport by default; --transport tarball switches to the whole-image stream.

--no-registry forces local-shipping for every service (offline/airgapped deploys, or shipping a locally-modified version of a public image), skipping registry pulls entirely.

How the unregistry transport works (default)

For each host:

  1. yoink starts an ephemeral ghcr.io/psviderski/unregistry sidecar. Unregistry is an OCI registry that reads/writes the host's image store via the containerd socket with no separate blob storage; images land in docker images immediately on push.
  2. yoink opens an SSH-tunnelled local port to the sidecar.
  3. yoink reads the image bytes from the operator's docker daemon and pushes blob-by-blob over the SSH tunnel: HEAD first, PUT only if missing. Layer-level dedup: redeploys ship only the changed layers.
  4. The sidecar is --rm and force-removed by name on the next up via a label sweep.

The push runs from the operator process, not via docker push on the operator's daemon. On macOS Docker Desktop (and Rancher Desktop / Colima) the daemon lives in a Linux VM, so a docker push 127.0.0.1:<port>/... from the daemon would hit the VM's loopback rather than the operator's. Pushing from the operator process avoids any insecure-registries config.

Multi-host fan-out is concurrent across services: deploy time is max(per-host), not sum(per-host).

โœ“ api:dev โ†’ host-1     done ยท unregistry
โœ“ api:dev โ†’ host-2     done ยท unregistry
โœ“ web:dev โ†’ host-1     done ยท unregistry

If the unregistry setup fails (host can't pull the image, ssh forward refused), --transport=auto (the default) falls back to tarball with one warning line. --transport=unregistry turns those into hard errors.

Tarball transport (opt-out)

yoink up --build --transport tarball

For each (service, host), streams docker save <image>:<tag> from the operator's docker daemon into the host's docker daemon via the same SSH-tunnelled Docker API connection up uses (POST /images/load). The whole image crosses the wire every deploy with no dedup. Slower than unregistry on redeploy. No host dependencies beyond docker. For air-gapped hosts that can't pull the unregistry image, or for debugging transport issues.

Per-host progress bars track bytes transferred + rate live in tarball mode:

โ ‹ api:dev โ†’ host-1     234.5 MiB @  47.0 MiB/s (tarball)
โœ“ web:dev โ†’ host-1     done ยท 89.3 MiB (tarball)

When standalone mode is the wrong fit

Fits: single host; hobby / prototype; air-gapped or restricted-network hosts.

Doesn't fit: many hosts (a registry is the natural hub); rollback by tag (registry keeps every pushed tag; local docker cache doesn't); audit requirements.

For your own registry without paying for one, see Self-hosted registry on a yoink host.

See also

On this page