Logos deployment
Logos deploys pull-based. Prebuilt images are published to the project's
Harbor registry (${LOGOS_HARBOR_REGISTRY}/logos) — latest tracks main,
and a pinned tag pins a specific build. Builds of main are mirrored to
public GHCR as well, so a self-hosted install pulls without credentials (see
the installation guide); deployments use Harbor. A
deployment is the compose file plus a .env on each node, and an update is
docker compose pull and docker compose up -d. Nothing is pushed to a node
from CI, and a worker node needs no inbound port at all (see the
architecture overview for the subsystem
breakdown).
A deployment consists of:
- One core node running the core stack: Traefik, the rate-limit gateway, orchestrator, web service, UI, and PostgreSQL.
- Zero or more worker nodes — GPU machines that run local vLLM models and connect out to the core node (see the worker node guide).
- Optionally, the agent stack on the core node, which runs coding agents
on spare serving capacity (see the
agent runner reference).
It is opt-in: enable it with
COMPOSE_PROFILES=agent.
Images and registry
| Image | Contents |
|---|---|
logos | orchestrator |
logos-webservice | Spring admin/statistics service and public inference gateway (/v1, /openai, /jobs) |
logos-ui | Angular frontend |
logos-db | PostgreSQL 17 plus pg_cron |
logos-rate-gateway | per-IP rate limiting (nginx) |
logos-agent, logos-agent-gateway, logos-agent-workspace | the agent stack (only with the agent profile) |
logos-workernode-vllm | the worker node runtime (on the GPU host) |
logos-workernode-mlx | the Apple Silicon worker — the only image not pushed to Harbor, and the only one that is never run as a container (see the MLX section below) |
REGISTRY (default ghcr.io/ls1intum/edutelligence, the public mirror) and
IMAGE_TAG (default latest) in the .env select the source. The deploy
workflows write ${LOGOS_HARBOR_REGISTRY}/logos and the deployed tag into
the .env they ship, so every node pulls from Harbor and logs in once
(docker login); the default is there for installs that have no Harbor
account.
The mirror is written by pushes to main only — a pull request builds to
Harbor alone, where the cleanup workflow prunes its pr-<number> tag, and a
release publishes its version tag there too. Nothing prunes a public package,
which is why PR and release builds stay off it. When a package appears for the
first time, check that it is public: a private one answers a self-hosted
install with a 403, and Package settings → Change visibility is the fix.
Updating
The core node updates in place:
# in the .env next to docker-compose.yaml
IMAGE_TAG=<new tag> # or keep "latest"
docker compose --env-file .env pull
docker compose --env-file .env up -d
The web service applies pending Liquibase migrations at startup, so a version jump is a normal event. Back up the persistent volumes first (see the installation guide).
Worker nodes update the same way from their worker directory: bump
IMAGE_TAG in the worker's .env, then docker compose pull and
docker compose up -d. Lane configuration and calibrated model profiles
persist in the worker's data/ volume, so an update does not reset them.
Security & rate limiting
The orchestrator's API surface is tiered:
- User-facing (
/v1,/openai,/jobs) — any valid Logos API key. Traefik sends these to logos-webservice (inference gateway). Pure-cloud named-model requests are answered there; local/logosnode and mixed traffic is reverse-proxied to the orchestrator on the compose network. - Cluster-internal (
/logosdb/scheduler_state,/internal/*) — the sharedLOGOS_INTERNAL_SECRET, never a user key./logosdb/scheduler_stateused to accept any Logos API key and was publicly routed; it is now secret-gated and only reachable from inside the stack (the agent runner polls it). - Operator (
/logosdb/providers/logosnode/*) — the root key, TLS only. - Monitoring (
/metrics) —PROMETHEUS_API_KEY; denies all when unset.
The API docs (/docs, /redoc, /openapi.json) publish the full endpoint map
and are off by default; set LOGOS_DOCS_ENABLED=1 in .env only where
needed (the dev compose enables it).
Layer 1: per-IP limits (nginx rate gateway, in-stack)
Every public request (443 and 8080) first passes the logos-rate-gateway
container — an nginx that enforces a request budget per client IP,
which Traefik cannot do natively (its rateLimit middleware counts per
service, not per client). Only then does the gateway forward to Traefik's
internal entrypoint (:8090, no published host port), where the normal
routers apply. TLS is terminated at the public entrypoint and re-signalled
via X-Forwarded-Proto, so services still see https.
Two budget classes, both answered with 429 when exceeded:
| Class | Paths | Default (avg rps / burst) |
|---|---|---|
| model | /v1, /openai, /logosdb/..., the UI, everything else | 30 / 60 |
| control | /jobs, /api, /docs, /metrics, /health, /ws | 5 / 20 |
Tune per deployment in the node's .env (all optional):
-
LOGOS_IP_RATE_LIMIT_AVG/LOGOS_IP_RATE_LIMIT_BURST— the model budget. -
LOGOS_IP_CONTROL_RATE_LIMIT_AVG/LOGOS_IP_CONTROL_RATE_LIMIT_BURST— the control-plane budget. -
LOGOS_RATE_LIMIT_WHITELISTED_IPS— frees specific IPs from all limits, e.g. the chair's benchmark rig or an office egress IP. Space- separated IPv4 addresses and CIDRs:# .env on the core node
LOGOS_RATE_LIMIT_WHITELISTED_IPS="129.79.32.0/20 1.2.3.4"Malformed entries are skipped with a line in
docker logs logos-rate-gateway— a typo in the.envdegrades to "no whitelist", it cannot crash-loop the gateway (and with it the whole stack). -
LOGOS_GATEWAY_TRUSTED_PROXY_CIDRS— the source ranges allowed to carryX-Forwarded-For. Defaults to172.16.0.0/12(the Docker bridge ranges), which matches no public client, so a header forged from the internet is stripped before it reaches the gateway. If the node sits behind the chair's nginx (or any other reverse proxy), add that proxy's CIDR here too — otherwise every client counts as that one proxy IP and the per-IP isolation is lost. Comma-delimited: the value is passed straight to Traefik's--forwardedHeaders.trustedIPs, whose CLI expects a comma list — a space-delimited value would leave the outer proxy untrusted (or invalidate Traefik's static config). The in-stack nginx gateway normalises commas to spaces itself, so the same value works for both:LOGOS_GATEWAY_TRUSTED_PROXY_CIDRS="172.16.0.0/12,129.79.32.0/20"This value covers the public entrypoints only. The gateway → Traefik hop has its own list (below), so replacing the default here can never cut the in-stack chain.
-
LOGOS_INTERNAL_HOP_TRUSTED_CIDRS— the sources trusted on the internal entrypoint (:8090), i.e. the rate gateway. Defaults to the private address space (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) because the gateway's address comes from Docker's network pool and is not necessarily a172.16.0.0/12one. Leave it alone unless the stack's networks are pinned to known subnets; the entrypoint has no published host port, so only containers of this stack can reach it. -
LOGOS_GATEWAY_UPSTREAM— where the gateway forwards (defaulttraefik:8090); only change if the internal entrypoint moves.
Symptom to watch for: if the internal hop is not trusted, Traefik
rewrites the gateway's X-Forwarded-Proto to http and every service
loses the TLS signal. The loudest consequence is worker nodes failing to
attach in a loop:
BRIDGE ERROR ══ /auth rejected with HTTP 400:
{"error":{"message":"TLS is required for logosnode auth/session endpoints ...
The 400 body names the scheme and X-Forwarded-Proto the orchestrator
actually saw. Verify the chain with the gateway's address and the trusted
list:
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' logos-rate-gateway
docker inspect traefik | grep -- '--entrypoints.internal8090.forwardedHeaders'
The dev compose runs the same gateway with looser defaults (300/600 and 50/100) so local benchmarking is not throttled; the direct ports 18080 and 18082 bypass it entirely (debugging).
Layer 2: per-service limits (Traefik)
Both compose files attach generous Traefik rateLimit middleware to the
API routers (429 when exceeded). These count per service rather than per
client — they cap one service even when many IPs share the same key, which
the per-IP layer above cannot see. The higher-priority routers that actually
serve /api/* (webservice, agent, the logosnode operator paths) carry the
limiter alongside their strip-prefix middleware, so no request reaches a
service unthrottled:
| Middleware | Routers | Default (avg rps / burst) |
|---|---|---|
rl-model | /v1, /openai | 100 / 200 |
rl-jobs | /jobs | 50 / 100 |
rl-admin | /health, /docs, /metrics, /logosdb/providers/logosnode, the orchestrator's /api fallback, and the higher-priority /api/* routers (webservice identity/config/admin/WebSocket, logosnode operator actions, agent) | 20 / 40 |
The limiters are defined on the Traefik container itself, not on one of
the services: every app container references at least one of them, so
they must survive any single service's restarts (the same reason
strip-api lives there).
Tune per deployment via .env: LOGOS_RATE_LIMIT_MODEL_AVG,
LOGOS_RATE_LIMIT_MODEL_BURST, LOGOS_RATE_LIMIT_JOBS_AVG,
LOGOS_RATE_LIMIT_JOBS_BURST, LOGOS_RATE_LIMIT_ADMIN_AVG,
LOGOS_RATE_LIMIT_ADMIN_BURST. The dev compose uses higher defaults
(500/1000, 250/500, 100/200) so local benchmarking is not throttled.
Per-API-key request budgets on the model paths are enforced inside the
orchestrator (per key's configured cloud_rl/local_rl); the in-stack limits
are the backstop for unauthenticated or leaked-key abuse.
Optional: an additional per-client brake in the nginx in front
The stack no longer needs per-client limits in the chair's nginx ingress — they now live in the stack and travel with the deployment. If you still want a second brake there (to protect other vhosts on the same box, or to shed a flood before it reaches the node's IP at all), the same nginx pattern applies:
# Per-client flood protection for the Logos API. Generous on purpose:
# real clients (coding assistants, benchmark drivers) stay far below these
# rates; this is a brake, not a budget.
limit_req_zone $binary_remote_addr zone=logos_api:10m rate=30r/s;
server {
# ...
location / {
proxy_pass http://logos-core:443;
limit_req zone=logos_api burst=60 nodelay;
}
}
Adjust the zone/rate to the deployment's traffic; limit_req_status 429;
keeps the status code aligned with the in-stack limits.
Apple Silicon (MLX) worker nodes
MLX nodes do not follow the compose-based deploy path above, because Metal cannot be passed into a container: Docker on macOS runs a Linux VM with no GPU passthrough, so a containerised lane would silently fall back to the CPU.
Instead the image is a distribution artifact. On the Mac,
scripts/bootstrap-macos.sh pulls it, extracts the payload with docker cp
to ~/logos-workernode-mlx, and runs the worker natively under a launchd
agent. Running natively is also what keeps the orchestrator in control — a
native process can fork vllm serve on command, which a container could not.
Updating to a new version means re-running the bootstrap script on the node
instead of docker compose pull; it is idempotent and preserves config.yml,
.env and data/.
The orchestrator treats them as ordinary vLLM workers — no protocol change was needed. Sleep/wake is unavailable (it requires CUDA virtual memory), so the server reclaims memory by stopping and restarting lanes instead.
Full setup, sizing and troubleshooting: logos/logos-workernode/MACOS.md.
Inference gateway replicas
Public /v1, /openai, and /jobs traffic lands on logos-webservice.
The service has no fixed container_name, so Compose can run more than one
replica; Traefik load-balances them under logos-webservice-svc.
Set the desired count in the core node's .env and apply it on up:
# in the .env next to docker-compose.yaml
LOGOS_WEBSERVICE_REPLICAS=2
# Pass the same .env into Compose so the scale count is not expanded by the
# host shell (a bare ${LOGOS_WEBSERVICE_REPLICAS:-1} would default to 1 when
# the variable is only set in .env and not exported).
docker compose --env-file .env up -d --scale logos-webservice=2
Or, with Compose interpolating from .env:
# docker-compose.yaml (or a compose override) already uses:
# deploy.replicas / scale via env — prefer an explicit count on --scale,
# or export before invoking:
set -a && source .env && set +a
docker compose --env-file .env up -d \
--scale "logos-webservice=${LOGOS_WEBSERVICE_REPLICAS:-1}"
On the dev compose, drop or retarget the host publish 18082:8081 before
scaling — published host ports cannot be shared across replicas. Liquibase
serialises schema apply via its changelog lock; open SSE streams and the
short-TTL budget cache are the remaining per-instance state (see
gateway/InferenceGatewayController).
Optional .env knobs:
LOGOS_WEBSERVICE_REPLICAS(default1) — webservice replica count for the coredocker-compose.yaml. Cloud RPM is enforced shared across replicas; cloud TPM estimates remain per-replica — keep this at1when tight per-key TPM matters.LOGOS_GATEWAY_ENABLED(defaulttrue) — whenfalse, the gateway still accepts the public paths but proxies every request to the orchestrator after API-key auth.LOGOS_GATEWAY_BUDGET_CACHE_TTL_SECONDS(default15) — approximate budget overshoot bound; seeGatewayBudgetService.LOGOS_GATEWAY_BUDGET_RESERVATION_MICRO_CENTS(default1000000) — finalized cost reserved inlog_entry_costbefore each direct-cloud forward so concurrent admissions see the spend; reconciled (kept or zeroed) when the stream completes.
Environment variables
All runtime configuration lives in the .env file next to the compose file.
The .env.example in the repository
documents every variable with its default; the ones a fresh deployment must
set:
| Variable | Core node | Worker node |
|---|---|---|
LOGOS_DOMAIN | the core node's fully qualified domain | — |
ACME_EMAIL | the Let's Encrypt contact address | — |
LOGOS_INTERNAL_SECRET | a strong random string — the shared secret between web service and orchestrator | — |
KEYCLOAK_ISSUER_URI | your identity provider's issuer (https://<idp>/realms/<realm>) | — |
KEYCLOAK_ROLES_LOGOS_ADMIN / KEYCLOAK_ROLES_APP_ADMIN | the OIDC role names that map to the Logos roles (see roles) | — |
PROMETHEUS_API_KEY | optional — gates /metrics; unset denies all | — |
HF_TOKEN | optional — HuggingFace token for gated models; also distributed to connected workers | or set per worker |
LOGOS_URL | — | the core node's URL, e.g. https://logos.example.org |
LOGOS_API_KEY | — | the worker's shared key from the registration response |
The worker's hardware configuration — models, lane port range, vLLM
overrides — lives in its config.yml, never in .env. See the
worker node guide and the
detailed worker setup
for the registration request and troubleshooting.