A small container platform you drive over SSH. ssh harbr@your-box drops you
into a TUI where you add projects from git, deploy them, watch logs, edit
environment variables, assign domains, and roll back.
There is no web UI and no agent to install — the SSH server is the interface, and your existing SSH key is the login.
╭──────────────────────────────────────────────────────────────────────────────────────────╮
│ [ 1 DASHBOARD ] [ 2 PROJECTS ] [ 3 RESOURCES ] [ 4 SETTINGS ] [ ana@laptop ] │
│ │
│ R E S O U R C E O V E R V I E W [ CONTAINERS: 6 ] [ CORES: 20 ] │
│ ╭ HOST ──────────────────────────────────────────────────────────────────────────────╮ │
│ │CPU ███░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 6% 20 cores │ │
│ │MEM ██████████████░░░░░░░░░░░░░░░░░░ 44% 13.6 GB / 31.0 GB │ │
│ │DISK ██████████████████████████░░░░░░ 87% 202 GB / 233 GB │ │
│ │CORES ▂▁▄▂▁▁▃▁▂▁▁▅▂▁▁▂▁▃▁▂ 20 cores │ │
│ ╰────────────────────────────────────────────────────────────────────────────────────╯ │
│ ╭ NETWORK ↓ 33.0 KB/s ↑ 12.5 KB/s ───────────────────────────────────────────────╮ │
│ │ ▄ ▄ █ ▄ │ │
│ │ █▁ █▁▁█ █ ▁ ▁█▁ ▁ ▁▁ █ ▁ ▁█▁▁▁▁ ▁█ █▁▁▁█ ▁ │ │
│ ╰────────────────────────────────────────────────────────────────────────────────────╯ │
╰──────────────────────────────────────────────────────────────────────────────────────────╯
1-4 section ↑↓ move ↵ open / filter ? help q quit
Four sections, reachable at any time with the number keys shown in the nav.
Dashboard — host CPU, memory, disk and network throughput as live charts, alongside project health and recent deployments.
Projects — every project, filterable with /. Open one for tabs of
Services, Logs, Deploys, Env, Domains, Volumes and Access — plus Definition
when the project defines its own containers.
Resources — host meters including a per-core strip, a network history chart, and a breakdown of CPU, memory and IO by project or by container.
Settings — who may sign in, who is signed in right now, the login history, and the instance configuration.
Windows — one connection, several pages, driven like tmux. The prefix is a backtick:
| key | |
|---|---|
` c |
open a window |
` n p |
next, previous |
` l |
the window before this one |
` 1…8 |
jump straight to one |
` x |
close it |
` d |
detach, leaving everything running |
` ` |
send a literal backtick |
Watch a build in one window, read runtime logs in another, keep Settings open in a third. Background windows keep streaming, so a build log is complete when you come back to it rather than starting from wherever you happened to look.
A backtick is a character people type, so ` ` sends one through, and
while harbr has a text field open the prefix stands down entirely — nobody
expects a domain or an env value to swallow one. Pasted text passes through
whole, brackets and all, so pasting a command with a backtick in it into a shell
is not quietly corrupted. ctrl-] works as a prefix too, for when a backtick is
precious.
Shells are windows too. Several can run at once, and one that is not in front keeps working — output that arrives while you are elsewhere is replayed when you come back to it.
Losing the connection is not losing the session. A dropped link parks its
windows: the shells keep running, the log streams keep filling, and the next
connection with the same key lands back where it left off, skipping the gate.
` d does the same thing deliberately. q is the decision to leave, and
that closes them.
Underneath that:
- Projects from anywhere — public git over https, private git with a per-project deploy key, or a directory that already exists on the host.
- Compose without the compose CLI — harbr parses
docker-compose.ymlitself and drives the Docker Engine API directly. Builds go through BuildKit, so--platform=$BUILDPLATFORM,RUN --mount=…and heredocs all work. - Health-aware ordering —
depends_on: { condition: service_healthy }is honoured, and a deployment fails loudly if a service never becomes healthy. - Logs — streaming container logs per service, plus the full build log of every deployment, kept in the database.
- Environment variables — edited in the TUI, merged over the compose file's
environment:, with secrets masked in the list. - Domains — assign a hostname to a service and harbr writes the Traefik
labels, joins the container to the edge network, and brings up Traefik itself
on first use. With
HARBR_ACME_EMAILset you get Let's Encrypt certificates. - Two kinds of project — point one at a compose file in the repository, or define the containers in harbr itself: an image or a Dockerfile per service, with ports, command, memory and CPU limits.
- Volume mapping — mount a host path or a managed volume into any service from the Volumes tab. These sit on top of whatever the compose file declares, and win when they target the same path.
- Resource limits — compose
deploy.resources.limits(cpus,memory) and per-service limits set in harbr both reach the daemon. - Rollback — every deployment records the exact image id it ran, so rolling back re-runs the old images without rebuilding. The last three images per service are kept for exactly this; older ones are pruned after each successful deploy, and an image still backing a container is never removed.
Connecting lands you on a login gate that names the key you authenticated with.
- Known, approved key → press enter and you are in.
- Unknown key → the gate offers to file an access request. An admin sees it
under Settings › Access, presses
a, gives the person a name and a level, and they are in without reconnecting. - The very first key to connect claims the instance as admin. Set
HARBR_TOFU=0to disable that and pre-authorise keys withharbr key add.
Levels are additive — each contains the one before it.
| read | write | modify | admin | |
|---|---|---|---|---|
| view projects, logs, metrics | ✓ | ✓ | ✓ | ✓ |
| deploy, roll back, stop | ✓ | ✓ | ✓ | |
| edit env, domains, volumes, definition | ✓ | ✓ | ||
| create and delete projects | ✓ | ✓ | ||
| approve keys, grant access, instance config | ✓ | |||
| open a shell in a container | — | — | — | ✓ |
The split between write and modify is the point: an on-call operator can
restart a service without also being able to rewrite its secrets.
A shell is arbitrary code inside the container. It can read every secret,
rewrite every file and reach every service on the project network — which makes
it more dangerous than modify, and yet an operator with only write is
exactly who needs it at 3am. So it is not a rung on the ladder: it is an
independent flag, set per key and grantable per project, the way Kubernetes
treats pods/exec as its own verb.
- No level implies it. Only
adminhas it by default. - A grant can add it for one project without raising anything else.
- It still requires
readon the project — a key that cannot see a project cannot shell into it. - Granting it to a
readkey gives that key write access to the container's data. That is the point of the flag; be deliberate.
Press S on a project's Services tab. The shell opens in a window of its own
and takes the screen while it is in front: harbr hands the SSH channel to the
container raw, so everything in there behaves as it would over plain ssh.
` l leaves it running and goes back to the page you came from;
` x closes it. Every session is recorded — it is the one action where
harbr can see that it happened but not what was done.
Withdrawing shell access closes the shells it granted, rather than only refusing the next one. The same applies to a project a key can no longer read: any window holding it is closed on the next refresh, parked or not.
A key has a baseline level, and a grant on one project can raise it — never
lower it. A baseline of none means a key sees only the projects it was
granted, which is how you hand a contractor one project and nothing else.
ana baseline read → sees everything, changes nothing
ana @ billing = modify → full control of billing only
sam baseline none → sees nothing by default
sam @ billing = write → can deploy billing, cannot see marketing
Revoking or un-approving a key beats every grant it holds.
From the TUI — everything lives in Settings › Access, admin-only. Keys are listed with their project grants nested beneath them, so one person's whole access reads as a single block:
NAME / PROJECT LEVEL SHELL STATUS FINGERPRINT ONLINE
● admin-bootstrap admin yes active SHA256:lnpuW0s5RP… ● now
● sam read — active SHA256:RWPNnMcVjR…
└ billing modify yes on this project
| Key | On a key row | On a grant row |
|---|---|---|
a |
change level and shell access | change that grant |
g |
grant this key a project | — |
x |
revoke the key | remove just that grant |
The forms open on the current values, so renaming someone cannot silently reset their access. A project's own Access tab shows the same grants in context, along with your own level on it.
From the CLI — no public key file needed; keys resolve by name, full fingerprint, or fingerprint prefix:
harbr key list # baselines, shell flags, and grants
harbr key set sam --level write --shell # change a baseline
harbr key set sam --level read --no-shell # withdraw
harbr key grant sam billing --level modify # raise on one project
harbr key revoke sam billing # drop that grant
harbr key remove SHA256:… # delete the keyharbr key list shows grants beneath each key:
ana admin shell active SHA256:mj6Q4K5bpaP5jl9…
sam write shell active SHA256:dOcry8Hucs3xXP8…
└ billing modify shell
You cannot lock yourself out. Demoting, revoking or deleting the last active admin is refused, by both the TUI and the CLI — nothing except an admin can grant admin, so reaching zero would be unrecoverable:
$ harbr key set ana --level read
Error: ana is the only admin — promote someone else first
Settings › Activity shows who is connected now, every login attempt, and an audit trail of what was changed and by whom.
docker compose up -d --buildThen connect:
ssh -p 2222 admin@your-boxSee Who gets in for what happens on that first connection.
harbr resolves compose bind mounts to absolute paths and hands them to the
Docker daemon, which interprets them on the host. If harbr's data directory
were mounted at a different path inside its own container, every relative bind
mount in a user's compose file would point at a directory that does not exist.
The bundled docker-compose.yml therefore mounts /var/lib/harbr at
/var/lib/harbr. Keep it that way, or change both sides together.
| Variable | Default | Meaning |
|---|---|---|
HARBR_DATA_DIR |
~/.harbr |
Database, host key, deploy keys, checkouts |
HARBR_SSH_ADDR |
0.0.0.0 |
Listen address |
HARBR_SSH_PORT |
2222 |
Listen port |
HARBR_EDGE_NETWORK |
harbr-edge |
Network shared with the proxy |
HARBR_BASE_DOMAIN |
– | Wildcard base used to suggest hostnames |
HARBR_ACME_EMAIL |
– | Enables Let's Encrypt; without it, HTTP only |
HARBR_HTTP_PORT |
80 |
Host port for the proxy's HTTP entrypoint |
HARBR_HTTPS_PORT |
443 |
Host port for the proxy's HTTPS entrypoint |
HARBR_TOFU |
1 |
Enrol the first key that connects as admin |
HARBR_DETACH_TTL_HOURS |
12 |
How long a dropped session's windows are kept. 0 disables reattaching |
HARBR_TRAEFIK_IMAGE |
traefik:v3.6 |
Proxy image |
HARBR_LOG |
harbr=info |
tracing filter |
harbr serve # run the SSH server (default)
harbr doctor # check docker, git, proxy, key count
harbr key add laptop ~/.ssh/id_ed25519.pub --level admin
harbr key add oncall ~/.ssh/oncall.pub --level write --shell
harbr key list
harbr key remove SHA256:…| Where | Key | Action |
|---|---|---|
| Anywhere | 1 2 3 4 |
Dashboard, Projects, Resources, Settings |
| Anywhere | r ? q |
refresh, help, disconnect |
| Projects | / |
filter by name, slug or repo |
| Projects | n d s x R |
new, deploy, stop, delete, roll back |
| Project | tab [ ] |
Services, Definition, Logs, Deploys, Env, Domains, Volumes, Access |
| Project | d b esc |
deploy, change tracked branch, back |
| Services | S |
open a shell in the selected container |
| Deploys | R |
roll back to the selected deployment |
| Env / Domains | a e x v |
add, edit, remove, reveal secrets |
| Logs | n p f g G c |
switch service, follow, jump, clear |
| Resources | tab |
by project / by container |
| Volumes | a x |
mount, unmount |
| Access | a x |
grant a level on this project, revoke |
| Settings | tab a x |
section, approve or change a key, revoke |
- Fetch the source — clone or fetch, then check out the ref. A rollback pins the target deployment's commit.
- Build the service set: parse the compose file, interpolating
${VAR}from the repo's.envand the project's environment variables — or, for a containers project, compile the services defined in harbr into the same in-memory model. - Compile a plan: topologically sorted services, resolved images, port bindings, binds (compose volumes plus harbr mounts), resource limits, healthchecks, and harbr/Traefik labels.
- Build or pull each image. A rollback skips this and reuses recorded image ids.
- Ensure the network, volumes, and — if any domain is assigned — the proxy.
- Replace containers in dependency order, waiting for health at each step, and remove containers left over from services that no longer exist.
- Prune built images beyond the last three per service.
Every line of that is written to the deployment log and streamed to any connected session.
ssh client
│ public key auth, one pty per session
┌───────▼────────┐
│ russh server │ src/ssh
└───────┬────────┘
│ key bytes ─► decoder ─► ratatui frames ─► channel
┌───────▼────────┐ ┌──────────────┐
│ TUI │◄───────│ event bus │ deploy logs, status, sessions
│ src/tui │ └──────▲───────┘
└───────┬────────┘ │
│ actions │
┌───────▼────────────────────────┴───────┐
│ App │ src/app
│ job spawning · sessions · samplers │
└──┬──────────┬──────────┬───────────────┘
│ │ │
┌────▼───┐ ┌────▼────┐ ┌───▼─────┐
│ SQLite │ │ metrics │ │ engine │ src/engine
│ src/db │ │/proc + │ │ bollard │
└────────┘ │ stats │ └───┬─────┘
└─────────┘ │
┌───────▼────────┐
│ Docker daemon │
└────────────────┘
A deployment is compiled before anything is created: src/compose parses the
YAML, interpolates variables, sorts services by depends_on, and produces a
DeployPlan of fully-formed Docker API requests. That plan is a pure function
of its inputs, which is why most of the test suite needs no daemon at all.
What is enforced:
- Public keys only. The server advertises no other authentication method, so a client with no key is refused rather than prompted for a password.
- Unknown keys reach the gate and nothing else. They can file one request and read their own fingerprint. The queue is capped so it cannot be used to fill the database.
- Hostnames are validated before they become Traefik router rules. The rule
is built as
Host(`{host}`), so a domain containing a backtick could otherwise rewrite the matcher and steal another project's traffic. - Compose paths are validated at creation and at deploy time;
..and absolute paths are rejected so a project cannot read outside its checkout. - Git URLs may not begin with
-, which would otherwise be parsed by git as an option rather than a repository. - Environment keys are restricted to the shape Docker expects.
- Instances are isolated. Containers, networks and volumes are labelled with an instance id, so two harbr installs on one daemon cannot list, restart or delete each other's work.
- Deleting a project keeps its volumes unless you explicitly opt in, and requires typing the project slug.
- Permission levels are enforced on every action, not just hidden in the
UI: a
readkey is refused a deploy, awritekey is refused a config change, and a project a key has no level on is not listed at all. - Shell access is its own capability, off by default at every level except admin, and every session is written to the audit trail.
- Editing a key preselects its current level, so renaming someone cannot silently reset their access.
What is not:
- Deploy keys and environment values are stored unencrypted (mode 0600) under the data directory. Anyone with root on the host, or with the data volume, has them.
- Levels gate actions, not data. A
readkey still sees every environment value it has read access to, including ones marked secret once revealed. - harbr needs the Docker socket, which is equivalent to root on the host. Treat an admin key as a root credential.
cargo test --lib --test plan --test access # no daemon needed
cargo test --test docker # talks to a real Docker daemon
./scripts/e2e.sh # drives the TUI over real SSHThe three suites cover different things and none of them mock Docker:
| Suite | What it proves |
|---|---|
--lib |
validators, the compose parser, duration and port grammar, the key decoder |
plan |
dependency ordering, env precedence, volume and port translation, health checks, Traefik labels, instance labels |
access |
key lifecycle, permission resolution, per-project grants, shell capability, login history, restart reconciliation, log retention |
docker |
real deploys, health gating, redeploys, rollback by recorded image and by commit, image retention, containers-mode projects, volume mounts, interactive shells, pruning removed services, instance isolation |
scripts/e2e.sh |
authentication, the login gate, navigation, project creation, live logs, input validation, the request-and-approve flow, permission enforcement at each level, and a real shell driven over the SSH channel |
scripts/e2e.sh needs tmux; it starts a real server, connects a real ssh
client to it, and asserts on what is actually rendered in the terminal.
Deliberate omissions, not oversights:
- Redeploying replaces containers rather than draining them, so a service is briefly down during a deploy. There is no blue/green or rolling update.
- Compose support is a broad subset, not the full spec: no
profiles,extends,configs,secrets, multi-file overrides, or swarm keys. Port ranges are rejected with an explanation rather than silently mishandled. - No GitHub integration beyond cloning. There is no GitHub App, no OAuth and no webhook receiver, so no deploy-on-push — harbr has no HTTP listener at all.
- Runtime logs are not aggregated or searchable; you get Docker's buffer.
- Resource history lives in memory, so charts reset when harbr restarts and cover roughly the last ten minutes.
- Host metrics come from
/proc, which means Linux only. - The proxy is assumed to be harbr's — it creates and owns one Traefik container per host.
- One instance owns one host. There is no clustering and no scheduler.
- No backup or restore. The data directory (database, host key, deploy keys) is the whole of harbr's state — copy it yourself.
- Deploy keys and environment values are stored unencrypted (mode 0600) under the data directory.
- TLS has never been exercised. The ACME code paths are written but have never issued a certificate, because that needs a real domain and port 443. Treat Let's Encrypt support as unverified until you have run it.
- Concurrent sessions are capped at 64, but connections are not otherwise rate limited beyond russh's authentication delay.
- Windows do not survive restarting harbr. Parked windows live in the process, so an upgrade or a reboot closes them; only a dropped connection is survivable. Eight windows per session, and a background shell keeps its last 64KB of output, not its whole scrollback.
- A background shell is not rendered beside anything else. Each one is full screen when it is in front, because putting a live shell next to harbr's own views means hosting a terminal emulator — wide characters, alt-screen, mouse, scrollback — which is a project of its own rather than a flag.
- Reattaching is one workspace per key: if the same key is connected twice, each connection gets its own windows and the last one to drop is the one kept.