Agents report per-container net IO at heartbeat; the sleeper measures
idleness from those counters for remote services, removes replicas via
the agent queue, and repoints the domain route at the backend's new
/internal/wake-page endpoint (replacePath + priority-100 route in the
file provider). First hit serves a reloading page and retriggers the
wake reconcile, which recreates replicas and restores real upstreams.
Manual POST /services/:id/sleep also routes remote; local sleep/wake
flow is unchanged.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The agent now lists every containr-managed container's docker state at
each heartbeat; the server reconciles container_instances for rows that
node owns, so exits, OOMs, and crash loops surface in runtime state
between reconciles. Tombstoned rows are never resurrected.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Remote replicas publish an ephemeral host port; the agent reports the
assigned binding back in the create_container result and the backend
renders a per-service file-provider config into TRAEFIK_DYNAMIC_DIR
pointing Traefik at the node's mesh IP + port. Routes are rewritten on
every remote reconcile and removed on teardown or local placement.
Also: RetireService retires remote leftovers when a service returns to
local placement; remove_container is idempotent agent-side; tombstoned
replicas skip remove dispatch; the domains-stay-local ceiling is gone.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Operator-set node labels (node_agents.tags) gate which nodes a service
may schedule onto: services.placement_tags requires every listed tag.
auto and spread filter eligible agents by tags; tags are re-evaluated
each reconcile so retagging a node re-spreads replicas. Pin + tags and
local + tags rejected; empty array clears the requirement.
Surfaces: --placement-tags on services create/update, nodes update
--tags, MCP args, OpenAPI, node-detail tag editor, service-detail
placement-tags input.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
services.spread distributes replicas across every online, schedulable
agent (replica i → agents[i mod n], deterministic). auto placement now
orders by real headroom — memory utilisation, cpu usage, then container
count — instead of count alone.
Lifecycle (start/stop/restart/delete) and GET /runtime are now driven
by container_instances inventory, so remote replicas are visible and
tear down wherever they run — covers spread, pins, and stale pin
leftovers. Spread and explicit pins are mutually exclusive.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(scheduling): multi-node service placement + remote container lifecycle
services.node_id pins a service to a node agent: explicit id, "auto"
(least-loaded online node), or "local"/empty for the local Docker host.
- DeploymentEngine gains a NodeRunner seam; ReconcileService routes
pinned services to the agent's command queue (create_container with
image/env/command/ports/volumes/labels/resource limits), tracked in
container_instances with deterministic per-replica ids.
- Stop/start/restart/delete fan out to the pinned node — no more leaked
remote containers on service delete.
- Agent create_container is idempotent (rm -f first) and honors restart
policy, labels, memory/cpus, published+ephemeral ports, volumes, env.
- GET /nodes/options exposes id/name/status to authed users; full agent
management stays admin-only.
- CLI services create/update --node, MCP node_id docs, OpenAPI
node_id/node_name + /nodes/options, service-detail Placement section.
Ceilings: remote nodes run registry-pulled images only — git builds,
Traefik domains, and remote sleep detection stay local until
build-on-node and per-node ingress land.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(scheduling): node cordon/drain + schedulable gate
- node_agents.schedulable column; "auto" placement and explicit pins skip
cordoned nodes; already-pinned services keep running (reconcile is
unaffected — cordon is not eviction).
- POST /agents/:id/cordon|uncordon|drain (admin). Drain cordons, removes
every service container via the agent queue, and clears the services'
pins so they redeploy on the local host. Requires the node online —
an offline node can't acknowledge removals.
- CLI nodes cordon|uncordon|drain (drain confirm-gated), MCP
containr_nodes_cordon|uncordon|drain, OpenAPI paths + schedulable
field, node-detail page cordon badge + drain/cordon controls.
- Fix: re-registering an agent no longer resets auto_prune.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(scheduling): remote registry pulls + post-run health check
- Dispatch payload carries the project owner's registry credentials for
the image host; the agent docker-logins for the pull and logs out
after, so private images deploy to remote nodes.
- Completed command payloads get their registry password scrubbed —
secrets don't linger in agent_commands rows.
- Agent verifies the container is still running ~1.5s after docker run;
crash-loops now fail the deployment with the container's last log
lines instead of reporting "running" optimistically.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(branding): white-label — public /branding, admin write, shell theming
app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.
useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(invites): team invite links — admin create/revoke, public accept
Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.
- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
banners table (title/body/level/active/dismissible/schedule window).
GET /banners/active for signed-in users; admin CRUD under /admin/banners.
BannerBar renders above the header with per-banner dismissal
(localStorage); admin console gains an Announcements section;
`containr banners` manages them and `auth status` prints active ones.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
POST /projects/:id/import-compose deploys a compose file straight into a
project — no template row required. Well-known DB images become managed
databases attached to the project network under their service name as DNS
alias; other services deploy in dependency order via deployTemplateGraph.
- api: extract composeToSpecs converter (shared by the template
import-compose preview and the new project endpoint); fix dropped env
warnings (pointer receiver); replace insertion sort with sort.Strings
- cascade: database_services.project_id links graph-provisioned DBs to
their project so project delete tears down runtime + row (previously
leaked container + network)
- cli: new `containr import` group, `import compose <project> <file|->`
- mcp: containr_projects_import_compose tool
- openapi + regenerated frontend types; docs snapshot + CLI.md
- frontend: compose import modal on the project workspace toolbar
- tests: composeToSpecs conversion, ordering, db-image map, warnings
Phase D3. Managed databases can ship backup archives to a configured
S3-compatible destination (AWS S3, MinIO, Garage, …) while the local
copy remains intact.
- backup_targets table: owner-scoped endpoint/bucket/region/prefix +
encrypted access/secret keys (enc:v1:, never returned, has_credentials)
- create/update probe bucket access before storing; empty keys on update
keep stored credentials; delete refused (409) while referenced
- databases.backup_target_id assigns a target; external DBs rejected
- backup pipeline uploads via a holder container over docker cp streams;
remote failure keeps the local archive (logged), row gets remote:true
- restore prefers the local archive; on a missing local copy it downloads
the remote object into the backup volume first
- holder containers must be started before archive ops — docker cp does
not resolve volume mounts on never-started (Created) containers, and
removing the source mid-stream stalls the reader without EOF
- CLI: backup-targets list|add|update|remove|test + databases update
--backup-target; MCP: 5 new tools; Settings UI card + per-database
target picker + offsite badge on backups; OpenAPI + llms.txt + CLI.md
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Managed databases have always published their port on 127.0.0.1 only.
public_port opts into binding on all interfaces so the database is
reachable off-host. Toggling via PUT recreates the container — the
named data volume survives.
- create accepts public_port; PUT /databases/{id} toggles it
- external rows reject the flag (no container exists)
- CLI: databases create --public, databases update --public/--public=false
- MCP: databases_update tool; UI: create checkbox + per-row toggle
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Databases hosted outside Containr can now be registered alongside
managed ones. The connection is probed before storing (protocol-level
for postgres/redis-family via real drivers, TCP dial for the rest),
credentials are encrypted at rest and never returned, and external rows
skip managed lifecycle actions, backups, and Docker metrics.
- POST /databases/register-external — probe + create
- POST /databases/test-connection — ad-hoc probe (ok/latency_ms/error)
- POST /databases/{id}/test-connection — re-probe stored credentials
- provider/external fields on database services; privileged reads get a
composed connection URL with loopback hosts rewritten to the caller's
- CLI: databases register + test-connection; MCP: 3 new tools
- UI: managed/external toggle in the create modal, external badge and
host display, per-row test button, backups section hidden
- Migration widens the plan CHECK to admit 'external'
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Service lifecycle: POST /services/:id/clone duplicates config, volumes,
domains, access gates, builder settings, and variables (secret ciphertext
copied verbatim); POST /services/:id/move re-parents to another owned
project. Both enforce ownership, reject name conflicts, and audit.
Registries: CRUD at /registries — host-matched pull credentials for
prebuilt-image deploys, encrypted at rest via secrets pkg, never returned
(has_password flag). Empty password on update keeps the stored credential;
host normalized (scheme/case/trailing slash). Credentials thread through
BuildConfig → BuildRequest → pull auth config, used only for the pull.
Surfaces: CLI services clone|move + registries group, 7 MCP tools,
OpenAPI paths/schemas, frontend Lifecycle card (clone/move/delete) and
Settings registries section, llms.txt + CLI.md.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
services.builder picks the build strategy per service: auto detects from
repo contents (Dockerfile → railpack → nixpacks), explicit overrides force
a builder, and static builds assets in a node stage then serves them from
an nginx image (static_build_cmd/static_dir, port 80 default).
- cpu_reserve/memory_reserve → Docker CPU shares + MemoryReservation;
cpu/memory stay hard caps (NanoCPUs/Memory). Empty string clears.
- app_settings.default_cpu/default_memory override service-create
fallbacks; capacity_policy=block fails deploys whose requested memory
exceeds node capacity (default warn logs via slog).
- Resource specs are validated — "512mb" fails instead of silently
dropping the limit.
- Fixes: deployments.go hardcoded nixpacks (auto-detect was dead code);
DockerfileBuilder passed the Dockerfile *path* as build-context content;
BuildImage never drained the daemon stream so builds could abort and
errors went unseen — stream is now consumed and errorDetail surfaced.
- CLI: --builder/--cpu-reserve/--memory-reserve/--static-cmd/--static-dir
on create+update; MCP args on services create/update; UI Build &
Resources card; OpenAPI + llms.txt + CLI.md.
Verified live: static build of a real repo → nginx image → deployed;
reserves reach HostConfig (shares=256, MemoryReservation=128Mi);
validation rejects bad specs; builder persists + clears.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
service_domains table holds the full hostname set; services.domain stays
the derived default and is synced on every domain write. Runtime spec
carries all domains into a multi-Host Traefik rule plus access gates.
- GET|POST /services/:id/domains, DELETE + POST /:domain_id/default
- GET /services/:id/domains/check — DNS preflight vs public_ip/base_url
(ok | wrong-target | pending), stamps last_checked_at
- maintenance_mode → Traefik redirectregex to /api/v1/maintenance
(base_url/BASE_URL setting; falls back to noop@internal offline)
- basic_auth [{username,password}] → bcrypt htpasswd on the label;
responses return usernames only
- First domain auto-defaults; domain="" via PUT clears all domains
- CLI: services domains list|add|remove|default|check, update
--maintenance/--basic-auth; MCP: 5 domain tools + update args
- Settings UI: Domains card (CRUD + DNS check badges) + Access card
- Fixes latent C1 bug: PUT without volumes 500'd — nil []byte sent as
bytea, not NULL; COALESCE needs untyped nil + ::jsonb cast
Verified live: domain CRUD + default sync, pending/wrong-target checks,
bcrypt htpasswd and both middlewares on the deployed container's labels.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
services.volumes JSONB [{type: volume|bind, source, target, read_only}]
mapped into every replica's container config at deploy and reconcile
time, hashed into the runtime spec so mount changes trigger
reconciliation. Mounted via existing docker.HostConfig.Mounts.
- RuntimeSpec.Volumes + specHash + mount.Mount mapping in createReplica
- Create/update service accepts volumes; PUT replaces the whole list
- Replica-count reconcile now goes through the deploy queue — the last
raw `go` reconcile left that could race a build
- GET /admin/volumes inventories node volumes with in_use flags;
DELETE /admin/volumes/:name removes unused ones (409 on in-use)
- CLI: services update --volume/--bind/--clear-volumes, volumes
list|delete; MCP: services_update + volumes_list/delete
- Settings UI: Volumes section with mount editor
- Migration normalizes the nullable baseline column to NOT NULL [].
Verified live: deploy applies mounts to the container, docker
auto-creates named volumes, in-use deletion is refused.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Add cnp_ personal access tokens so the CLI, MCP server, and external
agents authenticate without a browser session — the agent-first spine
from ROADMAP2 phase A. Modeled on agent_auth_tokens: raw token shown
once, only SHA-256 hash stored.
- user_tokens table (goose migration + sqlc), name/prefix/scope/expiry,
owner-scoped revoke, last_used_at stamping
- middleware: cnp_ bearer resolution, read scope gates mutations with
403 SCOPE_INSUFFICIENT, non-admin scopes cap is_admin even for admin
owners — a token never outranks its scope or its owner
- GET/POST/DELETE /api/v1/user/tokens + OpenAPI + generated types
- Settings UI: create with scope/expiry pickers, one-time token display
with copy, list with last-used/expiry, confirm-revoke
- docs: OPERATIONS.md section, README feature line
Verified end-to-end against live Postgres: create/list/revoke, read
scope blocks PUT (403), write PAT on admin owner blocked from admin
routes (403), admin scope works, revoked token returns 401.
Authored By: TDvorak <[email protected]>
- Health checks for local services dialed 127.0.0.1 from inside the backend
container — the host's published port is unreachable there. Service-bound
checks now resolve the service's DNS name on its project network (backend
joins on demand), falling back to the service's stored port. A resolver
failure surfaces as TARGET_UNRESOLVED instead of a fake loopback probe.
- Checks lived only in memory: a backend restart silently deleted every
configured health check. ha_health_checks table + write-through on
add/update/tick and hydrate-on-boot, matching the scaling-policy pattern.
- sqlc schema.sql gained the runtime columns added by later migrations —
the stale snapshot already caused one silently-dropped-column bug.
- OpenAPI: runtime response now documents ports and health fields; types
regenerated.
- Buttons no longer wrap ("+ new project" rendered on two lines).
Verified live: service check probes whoami-web:80 via DNS → healthy;
check survives a backend restart and rehydrates.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Backend: service_templates.is_public lets owners publish user templates to
the anonymous catalog (GET /api/v1/templates), mirroring Railway's
marketplace publish flow. Migration, sqlc queries, visibility rules, and
openapi.yaml updated; types regenerated.
App: template editor gains a "List in the public catalog" checkbox;
Public badge on catalog cards and detail view.
Site: new templates.html catalog page - search, category filters, Official
vs Community badges, detail modal with variables and copyable import JSON.
Fetches the live catalog from a reachable instance (?api= or localStorage
override) and falls back to templates.json, generated from SeedTemplates
via cmd/dumptemplates. Shared styles extracted to style.css.
Vercel: project connected to GitHub repo with rootDirectory=site;
.vercelignore limits CLI uploads to site/ so `vercel deploy --prod` from
repo root matches git builds.
Authored By: TDvorak <[email protected]>
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Per-node onboarding tokens replace the placeholder <token> in the agent
connect command: raw cagt_ tokens are returned once at issuance, only
sha256 hashes persist in agent_auth_tokens, and listing exposes metadata
only. Public agent routes accept env-configured shared tokens or any
active DB-issued token; successful validation stamps last_used_at and
revoked tokens are rejected — verified end-to-end against a real stack
(register, heartbeat, revoke → 401, bogus → 401).
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Phase 0 cleanup verified end-to-end:
- Delete vendored APwhy runtime (cmd/apwhy, internal/apwhy) and the
unwired copy (internal/api/server.go + static bundle + cloned
internal/{storage,analytics,auth,rbac}) — ~7.5k lines of dead code.
- Keep Containr's native internal/gateway; move APwhy management
routes under /api/v1/gateway/* so they stop colliding with the
native /services paths. Fix handlers against the real schema:
UUID ids via RETURNING, integer enabled flags, plan validation,
service validate endpoint, unique-violation mapping.
- Port agents.go off GORM onto sqlc (sqlc/queries/agents.sql);
drop gorm dependencies; fix sqlc schema drift (services.environment_id).
- Replace the SELECT 1 goose baseline with the real consolidated
schema (52 tables) minus APwhy's parallel auth/RBAC objects;
idempotent via IF NOT EXISTS / OR REPLACE / guarded DO blocks so it
replays cleanly over legacy installs. Add a drop migration for the
dead objects and delete the legacy filename-tracked migrations/
loader, directory, and Dockerfile copy. Verified via cmd/migrate:
fresh install 53 tables, legacy upgrade clean to 20260917000000.
- Canvas: add invisible React Flow handles to ServiceNode so inferred
auto-connections stop emitting warning #008.
- Remove reactflow dep (superseded by @xyflow/react), test harness
cmds, and legacy 0001_init.sql backups.
Authored By: TDvorak <[email protected]>
Backend:
- GET /services/:id/metrics with real Docker container stats
- DELETE /git/providers/:id and GET repo branches endpoint
Frontend:
- Service detail shows real telemetry or honest empty states
- Live status distinguishes idle/polling/reconnecting
- Git providers section in Settings; repo+branch pickers in Add Service
- Real logo, compact Projects header, canvas fitView fix
- index.css deduplicated (1725 -> 1384 lines)
- Fixed smoke tests, lint errors, ToastProvider/context unification
Repo hygiene:
- Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README
- Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore
- Removed legacy root src/internal/cmd and stale configs/docs/artifacts
- Moved guides and design references under docs/
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>