Tank is a declarative container orchestrator for the BEAM, built entirely on Linx. You describe the pods that should run — their image, network, resources, and restart policy — as plain Elixir data; Tank persists that desired state in a Khepri store, and a reconcile loop converges the machine toward it, keeping it there across drift, crashes, and reboots.

It is the Kubernetes shape collapsed to a single node: you never imperatively start a container. You state intent with Tank.apply/1, and the loop makes reality match — start, restart-with-backoff, stop — until you change the intent. Tank is deliberately opinionated: macvlan networking, one consistent state tree.

This guide walks the whole surface, from a one-line pod to an interactive shell inside a running container.

Contents

Running the examples

Tank creates namespaces, mounts, and network interfaces, so it needs root. From a checkout, the repo's script starts a privileged iex:

./sudorun.sh

Everything below is typed at that iex prompt. The first time you reference an image, Tank pulls it from the registry and caches it under the data directory (see Operational config).

Declarative basics

Apply, list, get, delete

A pod is declared as a map. The smallest one names a pod and the single container it runs:

Tank.apply(%{
  name: "web",
  containers: [%{name: "app", image: "nginx:1.27"}]
})

apply/1 writes the desired state and returns :ok; the reconciler brings the pod up. The other verbs read and remove desired state:

Tank.list()        #=> [%Tank.Pod{name: "web", ...}]
Tank.get("web")    #=> {:ok, %Tank.Pod{...}}
Tank.delete("web") #=> :ok  (the reconciler tears the pod down)

apply/1 is create-or-replace: applying the same name again with new fields updates the pod in place.

Specs are validated structs

The map form is sugar. apply/1 validates it into a %Tank.Pod{} (with %Tank.Container{} and friends), and you can pass the struct directly:

pod = Tank.Pod.new!(%{
  name: "web",
  containers: [%{name: "app", image: "nginx:1.27"}]
})

Tank.apply(pod)

Tank.Pod.new/1 returns {:ok, pod} / {:error, reason}; new!/1 raises on invalid input. Validation is strict — an unknown field, a duplicate container name, or a mount referencing an undefined volume is rejected up front rather than failing at bring-up.

Image references

image: is an OCI/Docker reference, resolved against the registry with multi-arch selection for the host:

%{name: "app", image: "debian:13"}
%{name: "app", image: "ghcr.io/acme/api:1.4.2"}

For a rootfs you have already assembled on disk, the {:rootfs, path} escape hatch skips the registry entirely:

%{name: "app", image: {:rootfs, "/srv/images/custom"}}

Command, args, env, working directory

By default a container runs the image's own entrypoint. You can override any part of it, following the OCI rules:

%{
  name: "app",
  image: "debian:13",
  command: ["/usr/bin/myserver"],   # overrides the image Entrypoint
  args: ["--port", "8080"],         # overrides the image Cmd
  env: %{"LOG_LEVEL" => "debug"},   # merged over the image Env
  working_dir: "/srv"               # overrides the image WorkingDir
}

The resolution mirrors the OCI spec: argv is command ++ args when command is given (the image Cmd is dropped); otherwise it is the image Entrypoint followed by args (or the image Cmd if you give no args). env is the image's environment with your map merged over it. working_dir falls back to the image's WorkingDir, then /.

Resources

Volumes and mounts

Volumes are declared at the pod level and mounted into containers by name. A managed volume is allocated by Tank under its data directory; a {:host, path} volume bind-mounts an existing host directory (the escape hatch):

Tank.apply(%{
  name: "db",
  volumes: [
    %{name: "data"},                              # managed (default)
    %{name: "config", source: {:host, "/etc/db"}} # host path
  ],
  containers: [
    %{
      name: "postgres",
      image: "postgres:17",
      mounts: [
        %{volume: "data", path: "/var/lib/postgresql/data"},
        %{volume: "config", path: "/etc/postgresql", read_only: true}
      ]
    }
  ]
})

A mount's path is always absolute — it is the mountpoint inside the container. Every mount must reference a volume the pod defines, or apply/1 rejects the spec.

Limits

Per-container cgroup v2 limits are a map:

%{
  name: "app",
  image: "nginx:1.27",
  limits: %{
    memory: 256 * 1024 * 1024,   # bytes
    pids: 100,                   # max processes
    cpu: {50_000, 100_000}       # {quota_us, period_us} -> ~0.5 CPU
  }
}

cpu: {quota, period} is the cgroup CPU bandwidth pair: the container may use quota microseconds of CPU per period microseconds. {50_000, 100_000} is half a core; {200_000, 100_000} is two cores.

Restart policy

The pod's restart: is owned by the reconciler:

%{name: "web", restart: :always, containers: [...]}      # default
%{name: "batch", restart: :on_failure, containers: [...]}
%{name: "oneshot", restart: :never, containers: [...]}
  • :always — restart whenever the container stops, for any reason.
  • :on_failure — restart only on a non-zero exit or a signal.
  • :never — run once; never restart.

Restarts use exponential backoff: min(base · 2ⁿ, cap), reset after the container has run stably for a while. See The loop.

Networking

A pod is one network namespace. network: describes it. The two whole-netns shortcuts are atoms:

%{name: "web", network: :none, containers: [...]}   # isolated; loopback only
%{name: "web", network: :host, containers: [...]}   # share the host's network

:none is the default.

macvlan

The opinionated v1 model gives a container its own MAC and a real LAN IP via macvlan on a host uplink — no bridge, no NAT. Describe one or more NICs:

Tank.apply(%{
  name: "web",
  network: %{
    nics: [
      %{name: "eth0", parent: "eth0", ip: {"10.0.0.5", 24}, gateway: "10.0.0.1"}
    ],
    dns: ["10.0.0.1"]
  },
  containers: [%{name: "app", image: "nginx:1.27"}]
})
  • parent: is the host uplink the macvlan attaches to. It defaults to :auto, which resolves to the configured host uplink (see Operational config) — so you usually omit it.
  • ip: is {address, prefix} — a static IPv4 address and CIDR prefix.
  • gateway: adds a default route (optional).
  • dns: is pod-level — it becomes the container's /etc/resolv.conf. Omit it and the container inherits the host's DNS.

A pod's netns can hold several NICs, e.g. one per uplink:

network: %{
  nics: [
    %{name: "eth0", parent: "eth0", ip: {"10.0.0.5", 24}, gateway: "10.0.0.1"},
    %{name: "eth1", parent: "eth1", ip: {"192.168.5.5", 24}}
  ],
  dns: ["10.0.0.1"]
}

Loopback is always raised. (macvlan is commonly refused on Wi-Fi uplinks; on Wi-Fi-only devices use :host.)

The loop

Tank is level-triggered: you change desired state, the reconciler converges reality toward it. There is no imperative "start" — applying a pod is starting it.

Tank.apply(%{
  name: "ticker",
  restart: :always,
  containers: [%{name: "c", image: "alpine:latest",
                 command: ["/bin/sh", "-c", "while true; do date; sleep 1; done"]}]
})

Within moments the container is running. If its process exits or crashes, the reconciler restarts it per the pod's policy, backing off exponentially if it keeps failing — min(base · 2ⁿ, cap) — and resetting the backoff once it has run stably. Delete the desired state and the pod is gone:

Tank.delete("ticker")

Desired state is the source of truth; a control loop is the only thing that touches reality.

Operational config

Distinct from what runs (desired state) is where Tank keeps its stuff (operational config) — plain application config in config/runtime.exs.

Data directory

config :tank, data_dir: "/var/lib/tank"

Images, managed volumes, and per-pod scratch live here. Standalone Tank defaults this to a per-user cache directory (override with the TANK_DATA_DIR environment variable).

The store

Desired state lives in a Khepri (Raft-backed) store. Tank either boots a default store or attaches to one a consumer already runs:

# Standalone: Tank boots and owns a store under data_dir.
config :tank, :store, data_dir: "/var/lib/tank/khepri"

# Bring-your-own: attach to a store the host already started, by name.
config :tank, :store, store_id: :my_store

With :data_dir set, Tank owns the store's lifecycle; with only :store_id, Tank assumes the consumer manages it. This is the "bring-your-own-or-boot-a- default" pattern.

Seeding pods at boot

Pods listed in config are written to the store create-if-absent on a fresh machine, so the boot seed never clobbers state you changed at runtime:

config :tank, pods: [
  %{name: "web", containers: [%{name: "app", image: "nginx:1.27"}]}
]

Config is a starting point, not a live mirror: removing a pod is Tank.delete/1, not deleting it from config. Runtime changes persist across reboots.

Host network facts

Tank reads two facts about the host's network — the uplink a macvlan attaches to (resolving parent: :auto) and the DNS servers a container inherits — through a swappable adapter, so it shares them without owning host networking. The default adapter reads them from config:

config :tank, Tank.Host.Static,
  uplink: "eth0",
  dns: ["10.0.0.1"]

With this set, a NIC can omit parent: (it defaults to :auto) and a pod can omit dns: — both fall back to these host facts. A consumer that manages host networking itself points config :tank, host: MyHostAdapter at its own Tank.Host implementation; Tank core never depends on it.

Interactive containers

A running container is not a black box — you can run commands inside it.

Tank.exec — a shell in a running container

Tank.exec/3 is the docker exec -it model: the pod's main process keeps doing its job while you run a second process — typically a shell — that enters the container's namespaces with a PTY. Leaving the shell does not stop the container.

Start a pod whose main process is a long-lived keepalive, then open a shell in it:

Tank.apply(%{
  name: "shell",
  containers: [%{name: "app", image: "debian:13",
                 command: ["/bin/sleep", "infinity"]}]
})

Tank.exec("shell", ["/bin/bash"])

You get an interactive bash prompt inside the container — its own filesystem, its own isolated process tree:

# cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
...
# ps -e -o pid,comm
    PID COMMAND
      1 sleep
      7 bash
     13 ps
# exit

The container's PID namespace is its own: the pod's keepalive is pid 1, and the bash you exec'd is a fresh process beside it. Typing exit ends only the bash — sleep infinity keeps running. Exec again whenever you like, and you can run several exec sessions into the same pod at once (each from its own iex).

Tank.exec/3 returns the session's terminal result:

Tank.exec("shell", ["/bin/sh", "-c", "echo hi; exit 3"])
#=> {:ok, {:exited, 3}}

You can also leave an interactive session without ending the command: press the detach sequence — Ctrl-P Ctrl-Q (docker's default) — and the call returns {:ok, :detached}, restoring your terminal while the command keeps running inside the container:

Tank.exec("shell", ["/bin/bash"])
# ... look around, then press Ctrl-P Ctrl-Q ...
#=> {:ok, :detached}

The exec session inherits the container's environment — the image's Env (so PATH resolves against the rootfs) plus a default TERM for a usable shell — and starts in the container's working directory. Override per call:

Tank.exec("shell", ["/bin/bash"], cwd: "/tmp", env: ["DEBUG=1"])

:env entries are merged over the container's own environment (last writer per key wins); :cwd overrides the starting directory.

Because the PTY is wired through your terminal, call Tank.exec/3 straight from iex (or anywhere the caller owns a terminal) — it blocks for the life of the session and restores your terminal cleanly when the command exits, even on a crash.

Tank.attach — taking over the main process

Tank.attach/1 is the docker attach model: instead of running a second process, the container's main process is the interactive program, and you take over its terminal. Declare the container with tty: true so its main process runs on a PTY:

Tank.apply(%{
  name: "console",
  restart: :always,
  containers: [%{name: "sh", image: "debian:13",
                 command: ["/bin/bash"], tty: true}]
})

Tank.attach("console")

Your terminal becomes the container's bash. Since ending that bash would stop the whole pod, leave without killing it by pressing the detach sequence — Ctrl-P Ctrl-Q — and re-attach whenever you like:

Tank.attach("console")
# ... work in the shell, then press Ctrl-P Ctrl-Q ...
#=> {:ok, :detached}

Tank.attach("console")   # back in the same bash, still running

If you do end the main process (type exit, or it crashes), attach/1 returns its terminal result and the pod stops — at which point the reconciler applies the pod's restart policy. With restart: :always, a fresh bash comes right back up.

Tank.attach("console")
# ... type `exit` ...
#=> {:ok, {:exited, 0}}

attach/1 returns {:error, :not_a_tty} if the container wasn't declared tty: true, and {:error, :not_running} if the pod has no live workload.

exec vs attach at a glance: use exec to get a shell beside a running service (the common case — the service keeps serving); use attach when the container is the interactive program and you want its own terminal.

Putting it all together

A realistic pod brings the pieces together — a real LAN IP via macvlan, a managed volume, resource limits, and a restart policy — declared as one map:

Tank.apply(%{
  name: "api",
  restart: :always,
  network: %{
    nics: [%{name: "eth0", parent: "eth0", ip: {"10.0.0.20", 24}, gateway: "10.0.0.1"}],
    dns: ["10.0.0.1"]
  },
  volumes: [%{name: "state"}],
  containers: [
    %{
      name: "app",
      image: "ghcr.io/acme/api:1.4.2",
      env: %{"PORT" => "8080"},
      mounts: [%{volume: "state", path: "/var/lib/app"}],
      limits: %{memory: 512 * 1024 * 1024, pids: 200, cpu: {100_000, 100_000}}
    }
  ]
})

That single call pulls the image, builds the rootfs, raises a macvlan interface holding 10.0.0.20 on the LAN, mounts the volume, applies the cgroup limits, and starts the container — then keeps it running, restarting with backoff if it crashes. To look inside while it serves, exec a shell beside it:

Tank.exec("api", ["/bin/sh"])

And the interactive flagship — a container that is a shell, which you can leave and return to without stopping it:

Tank.apply(%{
  name: "console",
  restart: :always,
  containers: [%{name: "sh", image: "debian:13", command: ["/bin/bash"], tty: true}]
})

Tank.attach("console")     # your terminal becomes the container's bash
# ... press Ctrl-P Ctrl-Q to detach, leaving it running ...
Tank.attach("console")     # right back where you were

Tank.delete("console")     # and it's gone

Everything above is desired state in Khepri with a loop converging to it: no imperative start, stop, or restart anywhere — just intent, and a loop that makes it true.