A self-hosted PaaS for servers you actually own.
Push a branch, get a healthy container behind TLS. NineDeploy builds from Git or pulls from a registry, keeps the previous container serving until the new one passes its healthcheck, and manages the databases, certificates, secrets, backups and access rules around it — from a web panel, a terminal CLI, a typed SDK, or an AI agent over MCP.
Website · Quickstart · Architecture · Templates · Changelog
One Node process, one SQLite file, and the Docker socket on a machine you control. That process builds your repositories, runs them as containers (or PM2 processes, or Compose stacks), writes Traefik's routing config, provisions Postgres/MySQL/Redis/Mongo/ClickHouse and friends, encrypts every secret and every backup it stores, and exposes the whole surface as a REST API that the dashboard, the CLI, the SDK and the MCP server all speak.
What it replaces: a hand-rolled pile of docker run invocations, nginx vhosts, certbot cron
jobs, pg_dump | gzip | aws s3 cp scripts, and a wiki page nobody has updated since the last
migration.
What it is not: a Kubernetes distribution, a multi-region control plane, or a hosted service. There is no scheduler, no service mesh and no cluster consensus — just one panel that knows how to drive Docker on one host, and optionally on a handful of remote ones.
| Host | Linux. The installer handles Debian/Ubuntu end to end (signed APT repos for Node and Docker); other distributions work if Node and Docker are already present. |
| Runtime | Node.js ≥ 22.13 (bare-metal mode only), Docker Engine + the Compose plugin. |
| Ports | 80/443 free for Traefik. The panel listens on port 3000 over plain HTTP. Bare metal binds 0.0.0.0 by default (NINEDEPLOY_HOST); if you reach the panel through Traefik or a tunnel, set NINEDEPLOY_HOST=127.0.0.1 in .env so it is not exposed directly. Docker mode publishes it on 127.0.0.1 by default (NINEDEPLOY_BIND). The panel's Doctor page (GET /v1/doctor) warns when the panel listens on a non-loopback address over plain HTTP. |
| Privileges | sudo for the installer in both modes. Bare metal uses it for the systemd unit, firewall and optional PM2. Docker mode still uses it for base packages and Docker itself (apt-get, systemctl enable docker), swap, ufw rules for 22/80/443, stopping a stock apache2/nginx that holds 80/443 on a fresh host, and creating /opt/ninedeploy-docker. Membership in the docker group is enough only for running and upgrading the stack with docker compose afterwards. |
curl -fsSL https://raw.githubusercontent.com/NineDeploy/NineDeploy/main/install.sh | bashInstalls Node and Docker if missing, drops a checksum-verified Nixpacks binary in place, downloads
the release tarball GitHub publishes for the resolved tag into ~/ninedeploy (falling back to a
git clone only if the tarball is unreachable; --channel main always uses git), generates a 32-byte JWT secret and a master key into a 0600 .env,
runs the SQLite migrations, and starts a hardened systemd unit (ProtectSystem=full,
NoNewPrivileges, PrivateTmp, Restart=always) gated on /health.
Re-running the same command is the upgrade path; it snapshots .data before touching anything.
./install.sh --version v0.10.42 # pin an exact tag
./install.sh --channel main # track edge
./install.sh --force # discard local edits + stale build artifacts, then rebuildWhere
mainstands: the default channel installs the newest release tag (0.10.42). The newest work (doctor mode, retained-volume re-keying) lands onmainbefore it is tagged — seeCHANGELOG.mdunder Unreleased, or run--channel mainto get it today.
Same installer, container mode — no host Node.js, no panel systemd unit, no PM2:
curl -fsSL https://raw.githubusercontent.com/NineDeploy/NineDeploy/main/install.sh | bash -s -- --dockerThe installer writes /opt/ninedeploy-docker/docker-compose.yml and a 0600 .env beside it, and
pins the panel image to the release it installed (NINEDEPLOY_IMAGE_TAG=vX.Y.Z in that .env;
edge on --channel main).
Because of that pin, a bare docker compose pull && docker compose up -d does not move you to a
newer release. To upgrade, re-run the same command (or ./install.sh --docker); to move by hand,
edit NINEDEPLOY_IMAGE_TAG in .env, then docker compose pull && docker compose up -d.
Or bring your own compose file (it refuses to boot without a strong NINEDEPLOY_JWT_SECRET):
echo "NINEDEPLOY_JWT_SECRET=$(openssl rand -hex 32)" >> .env
echo "DOCKER_GID=$(getent group docker | cut -d: -f3)" >> .env
docker compose -f docker-compose.prod.yml up -dOr run the image directly:
docker run -d --name ninedeploy \
-v /var/run/docker.sock:/var/run/docker.sock \
--group-add "$(getent group docker | cut -d: -f3)" \
-v ninedeploy-data:/data \
-p 127.0.0.1:3000:3000 \
-e NINEDEPLOY_DATA_DIR=/data \
-e NINEDEPLOY_DB_PATH=/data/ninedeploy.db \
-e NINEDEPLOY_JWT_SECRET="$(openssl rand -hex 32)" \
-e NINEDEPLOY_PUBLIC_URL=https://panel.example.com \
ghcr.io/ninedeploy/ninedeploy:latestDocker mode trade-off: PM2 services (host processes) and UFW firewall management need the bare-metal installer. Docker/Compose deploys, managed databases, Traefik ingress and encrypted S3 backups behave identically — the panel drives them as sibling containers through the mounted socket.
git clone https://github.com/NineDeploy/NineDeploy.git && cd NineDeploy
corepack enable && pnpm install && pnpm build
pnpm dev # API + built dashboard on :3000
pnpm dev:web # Vite dashboard on :5173 against that API- Open
http://<host>:3000. The first account created becomes the instance operator and owns a personal workspace; every later signup is an ordinary member until someone grants more. - Point a wildcard DNS record (
*.apps.example.com) at the host and set the ACME email under Settings → System; Traefik issues certificates from there on, DNS-01 wildcards included. ninedeploy doctorfrom your laptop, or the About page in the panel, reports what is missing.
Full matrix — environment variables, upgrade paths, systemd internals — in docs/QUICKSTART.md.
browser · ninedeploy CLI · SDK · MCP agent git push / PR event
│ │
│ HTTPS │ HMAC-signed webhook
▼ ▼
┌───────────────────────────┐ ┌────────────────────────┐
│ Traefik v3 │ │ POST /v1/hooks/:id │
│ :80 / :443 · ACME certs │ │ branch + path match │
│ per-domain middlewares │ └───────────┬────────────┘
└─────────────┬─────────────┘ │
│ routes by hostname │
▼ ▼
╔═════════════════════════════════════════════════════════════════════╗
║ NineDeploy panel — Fastify 5 API + React 19 dashboard, one process ║
║ ─────────────────────────────────────────────────────────────────── ║
║ 66 route modules (64 on /v1) deploy engine (queue + worker) ║
║ microkernel: events · hooks builders: docker · pm2 · compose ║
║ SQLite (52 tables, Drizzle) AES-256-GCM vault + key ring ║
╚═══╤═══════════════╤═══════════════════╤══════════════════╤══════════╝
│ docker.sock │ docker.sock │ HTTP + token │ S3 API
▼ ▼ ▼ ▼
app workloads managed data remote agents off-site backups
containers Postgres · MySQL (typed op calls; R2 · AWS · MinIO ·
PM2 processes MariaDB · Redis docker + compose Wasabi — db dumps
Compose stacks Valkey · Mongo deploys, Traefik and volume tars
130 templates ClickHouse · on each node)
Meilisearch ·
RabbitMQ
Cloudflare Tunnel ┄┄┄▶ reaches workloads on hosts with no inbound ports
Every workload joins a shared ninedeploy Docker network. Host ports are not published unless an
operator explicitly asks for one, and reserved ports (the panel, 80, 443, 22) are refused outright.
Triggered by the panel, a webhook, the CLI, a cron job or a template install:
- A deployment row is queued; a worker slot claims it atomically and snapshots the build config plus an env-key fingerprint — never the values — so the UI can diff deploy against deploy.
buildingrows older than 45 minutes are requeued on boot, so a panel restart mid-build recovers instead of leaving a ghost.- Prepare — image deploys pull (a rollback pins the exact digest). Repo deploys resolve source
credentials, check out the commit with the token or SSH key scrubbed from every log line, run a
best-effort framework analysis, and load
.ninedeployfrom the tree. - Environment — project-shared vars ← service vars ← attached-database connection strings, then
${{infisical:KEY}}/${{doppler:KEY}}references resolved at deploy time and never stored. A deploy fails if an attached database is not running. - Build — the manifest fills in whatever the panel left empty (
panel > manifest > auto-detect),runtime/phasesrender into a realnixpacks.toml, then the builder runs: Dockerfile via buildx, Nixpacks for Dockerfile-less repos, install-and-build under PM2 on the host, ordocker compose up -d --build. - Boot — the new runtime starts with secrets injected through a
0600temp env-file that is deleted immediately after start, plus networks, volume attachments, CPU/memory limits and the restart policy. The old container keeps serving. - Healthcheck — container liveness and an HTTP probe against the new container's network IP, retried with a fresh abort signal per attempt for up to five minutes.
- Cut over — only after the probe passes: finalize the row, persist the image digest, demote
older
runningdeploys tosuperseded, assign the wildcard domain, flip Traefik, wait two seconds, then retire the previous container. - On failure — the new runtime is torn down and the old one keeps serving; only the deployment is marked failed. (PM2 is stop-then-start, so it has no blue-green window.)
- Cancel — the pipeline re-reads its own status at every checkpoint and tears down partial work.
Logs stream over WebSocket while this happens, are stage-tagged (##[stage:BUILD:running]) for the
stepper UI, and persist to disk. Every subprocess has a hard timeout and is tree-killed.
Watch paths, [skip ci] handling, cancellation and PR previews: docs/DEPLOYMENTS.md.
- Blue-green with automatic rollback for Docker services, plus one-click rollback to any prior deploy by pinned digest.
- Three runtimes: containers, PM2 host processes, and Compose stacks (
ndcmp-prefixed). PM2 and Compose are host-privileged and therefore operator-only. - Ephemeral PR previews — a pull request spins up
service-pr-123with the parent's non-secret config (secrets are deliberately withheld, and the webhook response reports how many), capped by a max-active limit and destroyed when the PR closes. - Watch paths, branch filters and
[skip ci], so a docs commit does not rebuild the API. - Scheduled jobs — 5-field cron attached to any service, with preset editors, human-readable schedule descriptions and per-job run history.
- Per-deploy config diff — what changed in the build config and which env keys moved, without ever storing a value.
- Traefik v3 as the single public listener; dynamic config written atomically into a directory bind mount, with Host/Path operands sanitized against rule and YAML injection.
- Let's Encrypt HTTP-01 and DNS-01 (wildcards), with certificate expiry parsed out of
acme.jsonand fed into the alert evaluator. - Per-domain middlewares: www→apex redirect, custom response headers, basic auth, IP allowlists, rate limits.
- Cloudflare Tunnels for hosts with no inbound ports.
- Non-operators must prove control of a hostname before attaching it.
- One-click Postgres (with
pgvector), MySQL, MariaDB, Redis, Valkey, MongoDB, ClickHouse, Meilisearch and RabbitMQ. - Attach a database to a service and its connection string is injected as environment on every
deploy — resolved fresh from the vault rather than pasted into a
.envonce and forgotten. - Database Studio (binds a host port, operator-only), topology view, per-database resource limits.
- Streaming AES-256-GCM encryption on write for database dumps and volume archives; restore and download decrypt transparently, and legacy plaintext dumps still restore.
- Off-site sync to Cloudflare R2, AWS S3, MinIO or Wasabi, with retention pruning.
- Snapshots carry labels (
manual,schedule-…, operator tags) that surface on the Backups page alongside scope and volume names. - Restores are tar-slip validated before extraction and ownership-checked before they run.
- Workspaces carry membership and four roles (
owner/admin/member/viewer), enforced at the route layer: reads need any seat, writes needmember, deletion and re-tagging needadmin. - Instance operator is a separate, explicitly granted flag — not something you can hand yourself by creating a workspace — and it gates the host-privilege boundary: PM2, Compose, deploy lifecycle hooks, Docker-socket templates and published ports.
- API tokens carry scopes (
read/write/operator) applied centrally in the auth plugin, so a CI or MCP token can never outrank its owner. Tokens support expiry. - Services can be tagged into many workspaces, projects and labels at once, and the whole panel filters by all three — AND across the groups, OR inside one.
- AES-256-GCM in versioned envelopes with a rotatable master-key ring and a re-encryption job; older envelopes stay readable.
- SSRF egress guard on the operator-supplied outbound URLs whose targets are normally public:
notification channels, log drains, push delivery, git remotes, OAuth token exchange, the
marketplace catalog and
templates_source. Private, loopback and link-local targets are refused, closing the169.254.169.254metadata path. It is deliberately not applied to the OIDC issuer, the S3 endpoint, Vault, the log-search backend or the telemetry endpoint — self-hosted Keycloak, MinIO and Loki normally are on a private address, so guarding those would break working installs. Those settings are operator-only, and an operator can already run host commands through a service. - Subprocesses inherit a whitelisted environment (never
NINEDEPLOY_*), and the panel refuses to boot in production with a known-insecure JWT secret or a weak master key. - SSO and 2FA: OpenID Connect (Google, GitHub, Keycloak, Okta), WebAuthn passkeys, TOTP with replay protection, login lockout, and per-session revocation.
- Remote hosts run the same binary with
NINEDEPLOY_AGENT=1and register with a one-time token. The panel drives them through typed operations from a fixed table (~24 ops) rather than a command line, with every operand regex-validated on both ends. - Deploy to a node, not through the panel. A
dockerservice pinned to aserver_idis built and started on that host through the typed agent protocol: the repo is checked out in a per-service workspace on the node, the image is built or pulled there, and the environment arrives as a 0600 env-file that is deleted the moment the container has taken it. - Each node runs its own Traefik. The node terminates TLS for the services that live on it, so you point the domain at the node and production traffic never hairpins through the panel. The panel stays the source of truth for domains, middlewares and certificate policy: it renders both Traefik configs and ships them to the node, which only writes them to a fixed path. A routing change refreshes every node automatically; the proxy is only recreated when the static config changed, so a domain edit is not an ingress interruption.
- Compose stacks run on a node too, which matters because most of the one-click template
catalogue is compose-shaped: the panel ships an inline stack's YAML (or the node checks the repo
out), writes the
.envand any volume-attachment override, and then runs the same preflight-then-up ordering the local builder uses —compose configandcompose pullcomplete while the previous revision is still serving, so a broken${VAR}or a bad tag fails the deploy without ever tearing the live stack down. The platform restart policy is applied afterwards, because a compose file with norestart:leaves every container dead after a reboot. - PM2 and Nixpacks (Dockerfile-less) builds on a node are refused at queue time with a reason naming the missing capability — the node agent has no operation for them, and a container on the wrong host is worse than a deployment you can read. See Known limits.
- Live deploy logs and a container exec terminal over WebSocket, plus a container file browser and a volume browser.
- 30-second metric samples feeding threshold alert rules (CPU, memory, certificate expiry) with a breaching → firing → recovered state machine and a 30-minute anti-spam cooldown.
- Log drains to syslog, Loki, Vector, Datadog or plain HTTP.
- An audit trail with a global event stream, and per-repository framework insights.
- A microkernel with an event bus, waterfall hook pipeline, service registry, config center and
dynamic menu registry; plugins are written against
@ninedeploy/plugin-sdk. - 130 one-click templates (n8n, Directus, PocketBase, Ollama, Hasura, …) across twenty categories.
- One-click panel self-update: a dashboard banner runs this install's own
install.shfor the pinned tag, detached throughsystemd-runso it survives stopping its own unit, and reports the installer output tail when it fails.
Keep the deployment shape next to the code. The pipeline applies it under a strict
panel > manifest > auto-detect precedence and announces every value it contributed in the deploy
log.
version: "1"
runtime:
type: node
version: "22"
build:
install: pnpm install --frozen-lockfile
build: pnpm build
start: node dist/server.js
run:
port: 3000
healthcheck: /healthz
restart: on-failure:3
env:
required: [DATABASE_URL, STRIPE_KEY]
watch:
paths: [apps/api/**, packages/shared/**]
routes:
- host: api.example.com
path: /
ssl: true
previews:
enabled: true
pattern: pr-{n}.previews.example.com
alerts:
- when: highMemory
thresholdPct: 85
channel: ops-slackSixteen configurable sections beyond version — runtime, build, run, static, env, phases,
resources, hooks, watch, routes, previews, volume, database, network, notifications
and alerts — each with an editor in the panel's Manifest Creator, which also secret-scans what you
are about to commit.
ninedeploy manifest init # scaffold one from the repo
ninedeploy manifest validate # schema-check + block credential-shaped values before they reach git
ninedeploy manifest showhooks is the one section deliberately not honoured from the repo: lifecycle hooks execute on the
host, so accepting them from a commit would hand host execution to anyone with push access. A manifest
declaring hooks gets a warning in the deploy log instead of an execution.
Full reference: docs/NINEDEPLOY_MANIFEST.md.
npm install -g ninedeploy
ninedeploy init # set up and auto-start a local Docker server
ninedeploy login # or authenticate against an existing panel
ninedeploy doctor # environment and connectivity diagnostics
ninedeploy services list
ninedeploy services create # interactive wizard
ninedeploy services deploy 12
ninedeploy deploys watch 12 480 # stream a running deployment's logs
ninedeploy deploys cancel 12 480 # stop a queued or in-flight deployment
ninedeploy deploys rm 12 479 # drop a finished deployment from history
ninedeploy env set 12 NODE_ENV production
ninedeploy domains add 12 api.example.com
ninedeploy backups create 3
ninedeploy system dashboard # live health board40+ commands across services, deploys, env, domains, databases, backups, volumes, networks, sources,
webhooks, workspaces, users, sessions, alerts, firewall, plugins, config-center, templates and system
tooling. The token is stored 0600 in ~/.ninedeploy/config.json.
import { createClient } from '@ninedeploy/sdk';
const nd = createClient({ baseUrl: 'https://panel.example.com', getToken: () => process.env.ND_TOKEN! });
const services = await nd.services.list();
await nd.deploys.trigger(services[0].id);Typed namespaces over an injectable fetch, built from the same Zod schemas the server validates
with.
{
"mcpServers": {
"ninedeploy": {
"command": "npx",
"args": ["-y", "@ninedeploy/mcp"],
"env": {
"NINEDEPLOY_URL": "https://panel.example.com",
"NINEDEPLOY_TOKEN": "nd_tok_xxxxxxxxxxxx",
"NINEDEPLOY_MCP_READONLY": "1"
}
}
}
}38 tools over stdio, each mapping 1:1 onto the typed SDK so the MCP wire can never express
anything the HTTP API could not: inspection (list_services, service_logs, list_deploys,
inspect_container, topology, system_stats, activity_log, …), guarded actions
(deploy_service, restart_service, rollback_deploy, update_service, system_autoprune), and
configuration writes (set_config, install_plugin, enable_plugin, …).
The default surface mutates. NINEDEPLOY_MCP_READONLY=1 exposes only the non-mutating,
non-secret allowlist — and pair it with a read-scoped token so the restriction is enforced by the
server rather than by the client.
NineDeploy/ pnpm 11 workspace + Turborepo
├── apps/
│ ├── server/ Fastify 5 API, deploy engine, microkernel, agent mode
│ │ ├── src/engine/ pipeline · builders (docker/pm2/compose) · database ·
│ │ │ proxy · tunnel · logs · autoPrune · repoInsights
│ │ ├── src/modules/ 66 route modules + one aggregator + a compose helper
│ │ ├── src/lib/ crypto · jwt · sessions · totp · webauthn · oidc ·
│ │ │ resourceAccess (the authz choke point) · hostPrivilege ·
│ │ │ egressGuard · s3 · cloudflare · manifest apply
│ │ ├── src/kernel/ event bus · hook pipeline · config center · menus
│ │ └── src/templates/ 130-entry template registry
│ ├── web/ React 19 + Vite 8 + Tailwind v4 dashboard
│ └── cli/ `ninedeploy` (commander 15)
├── packages/
│ ├── db/ Drizzle schema (52 tables) + SQL migrations
│ ├── schemas/ Zod v4 DTOs shared by server, web, CLI, SDK and MCP
│ ├── sdk/ typed API client over an injectable fetch
│ ├── mcp/ Model Context Protocol server (38 tools, stdio)
│ └── plugin-sdk/ definePlugin + scoped config helpers
├── website/ marketing site, docs and template hub
├── docs/ 11 operator guides
├── install.sh one-command installer and upgrader
├── Dockerfile multi-stage, non-root, checksum-pinned Nixpacks
└── ARCHITECTURE.md the full internal design spec
pnpm test # every package
pnpm release:check # typecheck → lint → build → test
pnpm build && RUN_INTEGRATION=1 pnpm --filter @ninedeploy/server exec vitest run --config vitest.integration.config.ts
# testcontainers: real PG/MySQL/Redis/Mongo + deploy, compose, volume snapshot/restore| Package | Files | Tests | Coverage floor (stmts/branch/func/lines) |
|---|---|---|---|
apps/server |
328 | 5,003 | 93.25 / 87.3 / 91 / 95.1 |
apps/web |
106 | 1,747 | 97.5 / 91.5 / 97.5 / 97.5 |
apps/cli |
35 | 663 | 83 / 80 / 80 / 83 |
packages/schemas |
6 | 279 | 100 |
packages/sdk |
5 | 198 | 100 |
packages/mcp |
2 | 39 | 90 / 80 / 95 / 95 |
packages/db |
13 | 55 | 100 |
packages/plugin-sdk |
1 | 7 | 100 |
| Total | 496 | 7,991 |
Unit and route suites only — the server's seven testcontainers integration files (real Postgres,
MySQL, Redis and MongoDB backup/restore, a deploy end-to-end, a compose-stack deploy and a volume
snapshot/restore) live in apps/server/test/integration, which the default config excludes. They run only
through vitest.integration.config.ts with RUN_INTEGRATION=1, as their own CI job.
The server, web, CLI and MCP floors sit below 100, with the reasoning recorded inline in each
vitest.config.ts — an enforced gate beats an aspirational one that gets bypassed. CI runs
typecheck → lint → build → test, a schema-drift check, a deprecated-dependency check, a Docker image
build, and the integration job.
| Guide | What is in it |
|---|---|
| Quickstart & upgrading | Install modes, environment variables, systemd internals, in-place upgrades |
| Private repositories | GitHub PATs, server-generated SSH deploy keys, build-pack choice, auto-deploy webhooks |
.ninedeploy manifest |
Every section, precedence rules, and the manifest init/validate/show CLI |
| Deployments & pipelines | Blue-green, cancellation, watch paths, PR preview environments |
| Workspaces & RBAC | Tenancy, invitations, the role matrix, the instance-operator flag |
| Security & SSO | Vault design, OIDC, passkeys, TOTP, session handling |
| Databases & backups | Engines, injected connection strings, encryption, S3 destinations, restores |
| Ingress & tunnels | Dynamic routing, ACME HTTP-01/DNS-01, middlewares, Cloudflare Tunnels |
| Plugins & microkernel | Lifecycle hooks, dynamic menus, driver registries |
| AI, MCP, CLI & SDK | Tool reference, CLI reference, SDK usage |
| Troubleshooting | Socket permissions, database locking, healthcheck tuning |
| Architecture | The full internal spec — schema, lifecycle, security model, known gaps |
Stated plainly, because finding these out during an incident is worse than reading them here:
- Agent transport fails closed by default. Operations require sealed transport unless the
operator explicitly sets
NINEDEPLOY_AGENT_ALLOW_CLEARTEXT=1;NINEDEPLOY_AGENT_REQUIRE_SEALED=1overrides that opt-in. Connection tests and approval always use an authenticated sealed challenge and never send the raw token. Upgrade agents together with the panel: older agents withoutagent.pingcannot pass connection tests. There is still no TLS on the transport itself, so keep worker nodes on a private network or VPN. - Remote deployments cover
dockerandcomposeservices. PM2 (a host process with no agent operation) and Nixpacks source builds (no nixpacks on the node) are refused with a reason rather than silently run on the panel host. Add a Dockerfile, or clear the target server, to deploy those here. A node also refuses adockerservice whose container needs a template command, the Docker socket or attached volumes, a service whose repository the node would have to clone with a Git credential (the node clones anonymously), and a service backed by a panel-managed database (that hostname only resolves on the panel host). - Remote health is container state, not an HTTP probe. The panel sits outside the node's Docker
network, and publishing a host port purely to be probed would expose every remote service on the
node's public interface. A remote
dockerdeploy is healthy when the container reaches — and stays in —running; a remotecomposestack additionally has to pass its own declared healthcheck, and fails fast on a crash loop. Both are weaker signals than the local builder's HTTP check, and the deploy log says so. - The plugin marketplace is inert. The microkernel, hook pipeline and config center are real and drive the built-in plugins; the remote marketplace listing is not yet a live index.
- DNS rebinding is not solved by the egress guard; it validates at resolve time only.
- PM2 services have no blue-green window — they stop, then start, with auto-rollback on failure.
ARCHITECTURE.md §16 tracks these against the code, and marks each one as it is closed.
pnpm install
pnpm typecheck && pnpm lint && pnpm testBiome handles formatting and linting, Vitest handles tests, Turborepo caches both. Changes that touch
the database go through a hand-written migration in packages/db/src/migrations plus a case in
test/schema-drift.test.ts. Please keep CHANGELOG.md honest — it is a load-bearing document here.
MIT — see LICENSE.