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
- Declarative basics — apply/list/get/delete, images, the OCI command/env merge
- Resources — volumes, limits, restart policy
- Networking —
:none,:host, macvlan - The loop — how desired state becomes reality
- Operational config — data dir, the store, boot seeding
- Interactive containers —
Tank.execandTank.attach - Putting it all together — a flagship pod, end to end
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.shEverything 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_storeWith :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
# exitThe 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 runningIf 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 goneEverything 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.