- Go 49.4%
- Vue 37.6%
- TypeScript 7%
- CSS 4.8%
- Shell 0.5%
- Other 0.7%
A streamed deploy runs detached, so reloading the page (or a webhook-triggered deploy) left the client unable to follow its log — only cancel it. Add an in-memory fan-out hub per in-flight deploy that buffers emitted lines; the initiating stream and any later subscriber attach to it (replay so far, then live). New GET .../deploy/attach SSE endpoint re-attaches, emitting an "idle" frame when nothing is running so the UI settles into history. The editor auto-attaches on mount when the stack is "deploying". |
||
|---|---|---|
| .forgejo/workflows | ||
| cmd/agent | ||
| deploy | ||
| internal | ||
| web | ||
| .dockerignore | ||
| .env.dev.example | ||
| .env.example | ||
| .env.prod.example | ||
| .gitignore | ||
| .goreleaser.yaml | ||
| docker-compose.dev.yml | ||
| docker-compose.prod.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| install.sh | ||
| Makefile | ||
| README.md | ||
| sqlc.yaml | ||
Orion Harbor
A per-node agent that exposes a stable, protected REST API over Docker and Podman (via their Docker-compatible Unix socket), so multiple specialised frontends (CI/CD dashboards, app deployment panels, …) can drive containers without ever touching the raw engine socket.
It is, in spirit, a self-hostable building block for a Dokploy / Portainer-style platform: full container lifecycle, managed (declarative) applications, real-time logs/stats/events, OIDC auth and RBAC — with Docker Compose stacks planned next.
Why an agent in front of the runtime?
Docker and Podman both warn that direct socket/API access is equivalent to arbitrary code execution on the host. Orion Harbor sits in between:
frontends ── HTTP/JSON ──> Orion agent ── Unix socket ──> Docker / Podman
│
OIDC/JWT · RBAC · audit · normalized API
The public API is ours, not a 1:1 mirror of the engine API — frontends never couple to Docker- or Podman-specific shapes.
Architecture
cmd/agent entrypoint; subcommands: serve, migrate, dev-token
internal/
config env-driven config; auto-detects docker/podman sockets
runtime Adapter interface + normalized types (Workload, Event, …)
dockercompat one Docker-SDK adapter driving BOTH Docker and Podman
auth JWT verification: JWKS (OIDC, provider-agnostic) or dev HMAC
rbac role -> permission matrix
db pgx pool + embedded goose migrations
store sqlc-generated, type-safe queries
service managed application workflow (persist spec + deploy/reconcile)
audit audit event + sinks (slog + async Postgres)
api Gin router, middleware, handlers
Data layer: goose (migrations) + sqlc (type-safe queries) + pgx — no ORM.
Persistence is optional: with no ORION_DATABASE_URL the agent runs stateless
(power/logs/events/raw create still work); applications & persistent audit need the DB.
Quick start
# 1. (optional) a local Postgres for managed applications + audit
make db-up
export ORION_DATABASE_URL=postgres://orion:orion@localhost:5432/orion?sslmode=disable
make migrate
# 2. dev auth secret, then run (auto-detects your docker/podman socket)
export ORION_AUTH_DEV_SECRET=dev-secret
make run
# 3. mint a token and call the API
TOKEN=$(make -s token ROLES=deployer)
curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/v1/nodes
See .env.example for all settings, including production OIDC/JWKS.
Production install
One guided command installs the full stack — Traefik + Postgres + agent + web
UI — under /etc/orion-harbor:
curl -fsSL https://git.nhsoul.fr/nhpro/orion-harbor/raw/branch/master/install.sh | sudo bash
It detects your engine (podman/docker) and socket, then prompts for the public
domain, Let's Encrypt e-mail and Let's Encrypt staging (test certs). It generates
POSTGRES_PASSWORD and ORION_SECRET_KEY, writes /etc/orion-harbor/.env +
docker-compose.yml, pulls the (public) images and starts everything.
Prerequisites: DNS ORION_DOMAIN → this server's IP. First visit to
https://<domain> creates the local admin (or use OIDC).
Ports — only Traefik binds the host; everything else stays on the internal
orion-proxy network. The installer opens 80/443 (ufw/firewalld) automatically.
| Port | Service | Exposure |
|---|---|---|
| 80/443 | Traefik | host (open in firewall) |
| 8080 | agent API | internal only |
| 80 (web), 5432 (postgres) | web / db | internal only |
TCP/UDP stack routes need a dedicated Traefik entrypoint + published port: with
the compose-managed Traefik, add them to its command and ports, and open the
port. (The in-app agent-managed Traefik does this dynamically.)
Manual (equivalent):
cp .env.prod.example .env # fill ORION_DOMAIN, ACME_EMAIL, ORION_SECRET_KEY, ...
podman compose -f docker-compose.prod.yml up -d
Traefik is managed by the agent, not the compose: at first boot the agent
auto-provisions it (shared orion-proxy network, a default le ACME resolver
from ACME_EMAIL), binds host 80/443, serves the UI over HTTPS on ORION_DOMAIN
and routes every stack you deploy. Configure resolvers (incl. DNS-01 wildcard),
staging, log level and TCP/UDP entrypoints from the Reverse proxy menu — the
agent reconfigures Traefik live. The web UI is also exposed directly on
WEB_PORT (default 8088) as an admin fallback.
Update in place:
sudo /etc/orion-harbor/install.sh update # re-fetch compose, pull, up -d
Auth
The agent ships a built-in local admin (created on first run) that coexists
with OIDC. For more than one user, wire an OIDC provider
(ORION_OIDC_JWKS_URL / ISSUER / AUDIENCE) instead of adding local users.
API (v1)
Unauthenticated probes:
| Method | Path | Purpose |
|---|---|---|
| GET | /healthz |
liveness |
| GET | /readyz |
readiness (runtimes reachable) |
Everything below is under /api/v1, requires a bearer token, and is guarded by a
permission. :node selects a runtime node.
| Method | Path | Permission |
|---|---|---|
| GET | /nodes |
nodes:read |
| GET | /nodes/:node/capabilities |
nodes:read |
| GET | /nodes/:node/workloads ?all=true |
containers:read |
| POST | /nodes/:node/workloads (raw create) |
containers:write |
| GET | /nodes/:node/workloads/:id |
containers:read |
| DELETE | /nodes/:node/workloads/:id |
containers:write |
| POST | /nodes/:node/workloads/:id/power |
containers:power |
| GET | /nodes/:node/workloads/:id/exec (WebSocket) |
containers:exec |
| GET | /nodes/:node/workloads/:id/logs (SSE) |
logs:read |
| GET | /nodes/:node/workloads/:id/stats (SSE) |
containers:read |
| GET | /nodes/:node/events/stream (SSE) |
events:read |
| POST | /nodes/:node/images/pull (SSE) |
images:pull |
| GET | /nodes/:node/volumes |
volumes:read |
| POST | /nodes/:node/volumes |
volumes:write |
| DELETE | /nodes/:node/volumes/:name ?force=true |
volumes:write |
| GET | /nodes/:node/networks |
networks:read |
| POST | /nodes/:node/networks |
networks:write |
| DELETE | /nodes/:node/networks/:id |
networks:write |
| GET | /nodes/:node/images |
images:read |
| DELETE | /nodes/:node/images ?ref=...&force=true |
images:write |
| POST | /nodes/:node/images/prune |
images:write |
| GET | /registries |
registries:read |
| PUT | /registries (body {host,username,password}) |
registries:write |
| DELETE | /registries/:id |
registries:write |
| POST | /nodes/:node/applications ?deploy=true |
containers:write |
| GET | /nodes/:node/applications |
containers:read |
| GET | /nodes/:node/applications/:appId |
containers:read |
| POST | /nodes/:node/applications/:appId/deploy |
containers:write |
| DELETE | /nodes/:node/applications/:appId ?removeVolumes=true |
containers:write |
| GET | /nodes/:node/applications/:appId/deployments |
containers:read |
| POST | /nodes/:node/stacks ?deploy=true | ?dryRun=true |
stacks:write |
| GET | /nodes/:node/stacks |
stacks:read |
| GET | /nodes/:node/stacks/:stackId |
stacks:read |
| PUT | /nodes/:node/stacks/:stackId |
stacks:write |
| POST | /nodes/:node/stacks/:stackId/deploy |
stacks:write |
| POST | /nodes/:node/stacks/:stackId/stop |
stacks:write |
| DELETE | /nodes/:node/stacks/:stackId ?removeVolumes=true |
stacks:write |
| GET | /nodes/:node/stacks/:stackId/deployments |
stacks:read |
| GET | /nodes/:node/stacks/:stackId/services |
stacks:read |
| GET | /nodes/:node/stacks/:stackId/services/:service/logs (SSE) |
stacks:read |
Streaming endpoints use Server-Sent Events.
Compose stacks
A Stack is a stored Docker Compose project. Orion does not reimplement compose:
it validates with compose-go and applies with the compose CLI
(docker compose, docker-compose, or podman compose — auto-detected, overridable
via ORION_COMPOSE_CMD), running it against the node's socket. Per-service status and
logs are served from the runtime by filtering on the com.docker.compose.* labels.
Requires a compose binary on the node; without one, deploys return 503.
# validate without persisting
curl -X POST -H "Authorization: Bearer $TOKEN" \
"localhost:8080/api/v1/nodes/local-podman/stacks?dryRun=true" \
-d '{"compose":"services:\n web:\n image: nginx:alpine\n"}'
# create and deploy
curl -X POST -H "Authorization: Bearer $TOKEN" \
"localhost:8080/api/v1/nodes/local-podman/stacks?deploy=true" \
-d '{"name":"site","compose":"services:\n web:\n image: nginx:alpine\n ports:\n - \"8080:80\"\n"}'
Private images work: stored registry credentials are rendered into a Docker
config.json in the stack workdir and exported via DOCKER_CONFIG, so compose
authenticates pulls automatically (requires ORION_SECRET_KEY and configured
registries).
Private registries
Registry credentials are stored encrypted (AES-256-GCM, keyed by ORION_SECRET_KEY)
and resolved automatically when pulling an image whose host matches a configured
registry — for both raw pulls and managed deploys. Requires persistence and a
secret key; without them the /registries endpoints return 503.
export ORION_SECRET_KEY=$(openssl rand -base64 32) # 32 bytes, base64
curl -X PUT -H "Authorization: Bearer $TOKEN" localhost:8080/api/v1/registries \
-d '{"host":"registry.example.com","username":"alice","password":"token"}'
Managed applications
An Application is the desired spec of a container, persisted in Postgres.
Deploying it pulls the image, removes the previous instance (correlated via the
orion.app.id label) and creates a fresh container, recording each attempt in the
deployments history.
curl -X POST -H "Authorization: Bearer $TOKEN" \
"localhost:8080/api/v1/nodes/local-podman/applications?deploy=true" -d '{
"name":"web","image":"docker.io/library/nginx:alpine",
"ports":[{"container":80,"host":8080}],
"restartPolicy":"unless-stopped"
}'
Roles
| Role | Permissions |
|---|---|
viewer |
read containers, logs, events, nodes, volumes, networks, images |
operator |
viewer + power (start/stop/restart/kill) |
deployer |
operator + write (containers, volumes, networks, images, stacks) + exec + image pull + read registries |
game-admin |
same as deployer (game features later) |
node-admin |
everything (*), including managing registry credentials |
Auth
- Production: OIDC via JWKS —
ORION_OIDC_JWKS_URL(+ optional issuer/audience). Provider-agnostic (Keycloak, Authentik, Authelia, …). Roles come from a configurable claim (ORION_OIDC_ROLES_CLAIM, defaults toroles; Keycloak'srealm_access.rolesis handled too). - Development:
ORION_AUTH_DEV_SECRETenables HS256 dev tokens; mint withagent dev-token --sub alice --roles deployer.
Development
make build # compile
make test # unit + integration (integration needs ORION_DATABASE_URL)
make sqlc # regenerate store/db from SQL
make db-up/db-down
Roadmap
- V1 ✅ agent, runtime adapter, auth, RBAC, list/power/logs/events
- V1.5 ✅ full container lifecycle (create/rm/recreate/stats/pull) + persistence (managed applications, deployments, audit)
- V2 ✅ volumes / networks / images (list/remove/prune) + private registries (encrypted credentials, auto-resolved on pull) + test foundation
- V3 ✅ Docker Compose stacks (compose-go validate + controlled CLI apply, per-service status & logs, private-registry auth, deployment history)
- V4 notifications, backups, GitOps
- V5 game-server panel (last)