diff --git a/apps/docs/content/docs/core/autoscaling.mdx b/apps/docs/content/docs/core/autoscaling.mdx
new file mode 100644
index 00000000..a70ac0e9
--- /dev/null
+++ b/apps/docs/content/docs/core/autoscaling.mdx
@@ -0,0 +1,128 @@
+---
+title: Autoscaling
+description: "Three scaling surfaces — application replicas, live manual scaling, and cluster (node) autoscaling — all driven by the Nomad Autoscaler."
+---
+
+import { Callout } from "fumadocs-ui/components/callout";
+
+Nomploy has **three** related but distinct scaling surfaces. The word
+"autoscaling" shows up in more than one place, so here's the map first:
+
+| Surface | Where | What it scales |
+|---|---|---|
+| **Application autoscaling** | Application → **Advanced** → *Autoscaling* card | An app's replica count, automatically, between min/max. |
+| **Live scaling** | App → **Advanced** → *Scaling* card, and Compose → **Containers** | The running job's replicas, right now (manual, or reflecting the policy). |
+| **Cluster (node) autoscaling** | Nomad → **Autoscaling** tab | The number of **nodes** — provisions/destroys cloud VMs. |
+
+The first two scale *your workload's replicas*; the third scales *the cluster's
+machines*. All of them are driven by the Nomad Autoscaler.
+
+## Application autoscaling
+
+Turns an app's fixed replica count into a policy the Nomad Autoscaler manages.
+
+**Application service → Advanced tab → Autoscaling card.** Flip the switch on and
+set:
+
+- **Min replicas** (default 1)
+- **Max replicas** (default 3)
+- **CPU target %** (optional) — e.g. `70`
+- **Memory target %** (optional) — e.g. `80`
+
+Set at least one of CPU / memory target (you'll get a validation error
+otherwise), and keep max ≥ min. Click **Save**, then **redeploy** the app to
+apply — the policy is baked into the job's `scaling` block on the next deploy.
+With autoscaling **off**, the fixed replica count is used.
+
+Behind the scenes this emits a Nomad `scaling { min, max, enabled = true, policy
+{ … } }` block with `cpu`/`memory` checks against the `nomad-apm` source, using a
+`target-value` strategy.
+
+## Live scaling
+
+The **Scaling** card (app → Advanced; compose → Containers, Nomad only) shows
+each task group with its current `{running}/{desired}` count and, if it has an
+autoscaling policy, its `min–max` range and an **autoscaling** / **manual**
+badge. Type a number and click **Scale** to set the count immediately
+(`nomad job scale`). Useful for a quick manual bump or to observe what the
+autoscaler is doing.
+
+## Compose service autoscaling (`x-nomad-scaling`)
+
+For compose services, add an `x-nomad-scaling` policy to a service to get the
+same `scaling` block in the generated job — min/max plus CPU/memory targets —
+without the app-level form.
+
+## Cluster (node) autoscaling
+
+The Nomad page's **Autoscaling** tab configures the *node* autoscaler: the cloud
+provider **token** and the **SSH key** used to provision and join new machines.
+This is also the prerequisite for the Cluster tab's one-click **Add node**. It
+scales the fleet of servers/workers, not your app replicas.
+
+### Config knobs
+
+Per autoscaling group (Nomad node pool):
+
+| Knob | Meaning |
+|---|---|
+| **enabled** | Master switch for the group's reconcile loop. |
+| **min / max** | Hard floor / ceiling on the **total** worker count in the pool (manual + autoscaled). |
+| **desired** | The current target the loop drives toward, clamped to `[min, max]`. Manual sets, scheduled actions, and reactive pressure all move this. |
+| **scale-up CPU % / mem %** | Reserved CPU / memory at or above which the loop nudges the target up (defaults 80 / 75). |
+| **scale-down CPU % / mem %** | Reserved CPU / memory at or below which — both, with no blocked evals — it nudges the target down (default 25 / 25). |
+| **cooldown** | Minimum seconds between scale actions, so one node moves per cooldown instead of thrashing (default 300). |
+| **server type / location** | The cloud VM spec + region provisioned for new autoscaled nodes. |
+
+### How it decides (desired-count model)
+
+Each tick (~60s) the loop, per group:
+
+1. Measures **reserved** CPU/memory — what Nomad has *scheduled* onto the pool's
+ ready+eligible nodes (not live usage) — and counts **blocked evaluations**
+ targeting the pool (allocations that couldn't be placed).
+2. **Converges to `desired` first.** If `workerCount < desired` it scales **up**;
+ if `workerCount > desired` it scales **down** — but only ever removes an
+ **autoscaled** node. If the surplus is all manual/pinned it reports
+ `none (above desired … but the extras are pinned/manual)`.
+3. **Only when `workerCount == desired`** does it evaluate pressure: reserved
+ CPU/mem over the scale-up threshold (or any blocked eval) nudges `desired +1`;
+ slack under *both* scale-down thresholds nudges `desired −1` (never below
+ `min`). Because the nudge only happens *at* target, `desired` never runs more
+ than one node ahead of reality, and the cooldown paces one change per interval.
+
+Scale-up provisions a cloud VM (hostname-safe name), joins it to the pool, and it
+becomes a ready worker. Scale-down drains an autoscaled worker, removes it, and
+**destroys its VM** — so idle capacity stops billing.
+
+### Manual (pinned) nodes and the `min` floor
+
+A node added manually / one-click (`autoscaled = false`) is **pinned**: it counts
+toward `workerCount` but is **never reclaimed** by scale-down. That interacts with
+`min` in a way that's easy to get wrong:
+
+
+If a pool has one pinned worker but `min = 0 / desired = 0`, the loop is
+permanently in the "`workerCount(1) > desired(0)` but pinned" branch and **never
+reaches the pressure check** — so it will *never* scale up under load. Set
+`min = 1 / desired = 1` (with the pinned worker as the floor) and the loop sits
+"at desired," free to nudge up under load and back down when idle, with **zero
+extra idle cost** beyond the node you already run.
+
+
+### Example: elastic with a pinned baseline
+
+One pinned manual worker, burst up to two more under load:
+
+```
+min = 1, desired = 1, max = 3
+scale-up CPU ≥ 80% or mem ≥ 75% (or any blocked eval)
+scale-down CPU ≤ 25% and mem ≤ 25%
+cooldown 300s
+```
+
+- **Idle:** stays at the 1 pinned worker; no autoscaled VMs, no extra cost.
+- **Load:** reserved CPU crosses 80% → provisions an autoscaled VM (one per
+ cooldown) up to `max = 3`.
+- **Load clears:** reserved CPU/mem fall under 25% → removes autoscaled VMs and
+ destroys them, back down to the pinned floor of 1.
diff --git a/apps/docs/content/docs/core/load-balancer.mdx b/apps/docs/content/docs/core/load-balancer.mdx
new file mode 100644
index 00000000..eb0a626e
--- /dev/null
+++ b/apps/docs/content/docs/core/load-balancer.mdx
@@ -0,0 +1,99 @@
+---
+title: Load Balancer (HA ingress)
+description: "Turn the single hub Traefik into a highly-available, active/active Traefik pool with DNS failover, shared certificates, metrics and logs."
+---
+
+import { Callout } from "fumadocs-ui/components/callout";
+
+Nomploy's default ingress is a single Traefik on the hub. The **Load Balancer**
+tab turns that into a **highly-available, active/active Traefik pool** running on
+every node you tag, with DNS-based failover and its own metrics and logs.
+
+Open it from the sidebar (**Load Balancer**). It has three tabs: **Overview**,
+**Metrics**, and **Logs**.
+
+## How it works
+
+- **Pool** — a Nomad `system` job (`nomploy-traefik-ha`) runs one Traefik per node
+ whose Nomad meta has `nomploy_lb = "true"`. The hub is deliberately **excluded**
+ (it keeps the standalone Traefik), so standing the pool up never clashes on
+ `:80/:443` there and causes no downtime.
+- **Routing** — each instance reads routes from the local Consul catalog, exactly
+ like the hub, so every app/domain is served by every instance.
+- **Certificates** — TLS certs are shared through **Consul KV** (Traefik's `consul`
+ KV provider). Deploying the pool seeds the current certs from the hub's
+ `acme.json`; a background loop **re-syncs every 6h** so renewals propagate. The
+ Pool card lists each served domain with a **days-left** badge.
+- **Entry / failover (DNS)** — the pool gets a **generated hostname**
+ (`lb-.`) whose **A records are kept equal to the healthy nodes'
+ public IPs** (health-prune). Point your app domains at the pool by adding a
+ **CNAME** to that hostname. Failover is DNS-speed (records use a 60s TTL).
+
+## 1. Tag the pool nodes
+
+Give each node you want in the pool the `nomploy_lb` meta — from the Nomad tab's
+node tagging UI, or the CLI:
+
+```bash
+nomad node meta apply -node-id= nomploy_lb=true
+```
+
+Leave the hub untagged.
+
+## 2. Deploy the pool
+
+**Load Balancer → Overview → Deploy.** This seeds the certs into Consul KV and
+runs the system job on every tagged node. The member list shows each instance,
+its public IP, and status. Use **Sync certs** to force a cert re-seed, or **Stop**
+to remove the pool (the hub keeps serving).
+
+## 3. Configure DNS (optional but recommended)
+
+Requires a DNS provider in **Settings → DNS Providers** (Cloudflare).
+
+On the **Overview → DNS** card:
+
+1. Pick the DNS provider and zone, then **Generate hostname**.
+2. Flip **Manage DNS** on. The controller creates A records for the hostname
+ pointing at the healthy nodes and keeps them pruned (a 30s loop, plus
+ **Reconcile now**).
+3. **CNAME** your app domains to the generated hostname — the pool already serves
+ their routes and certs.
+
+
+If your zone has a proxied wildcard (`*.example.com`), the explicit A records
+nomploy creates are more specific and take precedence, so the hostname resolves
+straight to your nodes.
+
+
+## Metrics
+
+The **Metrics** tab shows pool-wide **throughput** (requests/s split by
+2xx/4xx/5xx) and **average latency**, over a selectable range (**1h / 6h / 24h /
+7d**), plus a live per-instance table (rate, status classes, latency, health,
+in-DNS). Data comes from Traefik's Prometheus endpoint, sampled every 60s into a
+rolling 7-day series.
+
+## Logs
+
+The **Logs** tab shows **consolidated** Traefik logs across the whole pool, each
+line **tagged with the instance** it came from. The access log is parsed into a
+searchable, color-coded table (time, instance, status, method, host/path,
+duration, client); switch to **Errors** for the app log. Filter with the search
+box, **4xx/5xx only**, or **Pause** the 5s auto-refresh.
+
+## Notes & limits
+
+- After enabling metrics/logs for the first time, **Redeploy** the pool so it
+ exposes the Prometheus `:8082` entrypoint and the access-log config.
+- Public IPs are auto-detected from the Hetzner API (via your **Settings → Cloud**
+ token); a node with no detected public IP is flagged and won't be published.
+- The pool serves TLS but does not issue it — a dormant `letsencrypt` resolver is
+ defined only so catalog routers validate; issuance/renewal stays on the hub.
+
+
+With the HA pool in front of **all** domains, Let's Encrypt **HTTP-01** no longer
+works (any node may answer the challenge). Switch issuance to **DNS-01** by
+configuring a DNS provider and redeploying your apps. See **Settings → DNS
+Providers**.
+
diff --git a/apps/docs/content/docs/core/meta.json b/apps/docs/content/docs/core/meta.json
index 893ca636..2552bdd6 100644
--- a/apps/docs/content/docs/core/meta.json
+++ b/apps/docs/content/docs/core/meta.json
@@ -29,6 +29,7 @@
"account-security",
"(Notifications)",
"registry",
+ "self-hosted-registry",
"secrets-providers",
"dns-providers",
"ssh-keys",
@@ -36,11 +37,13 @@
"backups",
"concurrent-builds",
"---Services---",
+ "overview",
"variables",
"domains",
"applications",
"docker-compose",
"databases",
+ "nomad-packs",
"(examples)",
"auto-deploy",
"schedule-jobs",
@@ -58,6 +61,10 @@
"remote-servers/validate",
"---Advanced---",
"cluster",
+ "load-balancer",
+ "autoscaling",
+ "telemetry",
+ "security",
"---Enterprise---",
"enterprise/index",
"enterprise/license-keys",
diff --git a/apps/docs/content/docs/core/nomad-packs.mdx b/apps/docs/content/docs/core/nomad-packs.mdx
new file mode 100644
index 00000000..1bc29a48
--- /dev/null
+++ b/apps/docs/content/docs/core/nomad-packs.mdx
@@ -0,0 +1,46 @@
+---
+title: Nomad Pack deploy
+description: "Deploy Nomad Packs from a one-click store gallery or any git registry — a variables form, deep-links, and a post-deploy smoke test, no HCL by hand."
+---
+
+import { Callout } from "fumadocs-ui/components/callout";
+
+[Nomad Packs](https://developer.hashicorp.com/nomad/tools/nomad-pack) are
+parameterised, reusable job templates. Nomploy can deploy them as a service type —
+either hand-picked from a git registry, or **one-click from a built-in store
+gallery** — so you get databases, tooling and infra without writing HCL.
+
+## Store gallery (one-click)
+
+The pack **store** lists curated packs from
+[`Nomploy/nomad-packs`](https://github.com/Nomploy/nomad-packs) as cards with
+**category, icon, stars and a health signal**. Pick one, fill its variables, and
+deploy — the store deep-links straight into a pre-configured Create Service flow.
+
+## Deploy a pack manually
+
+Create a **Compose** service and choose type **Nomad Pack**. The Deploy Settings
+card (**Nomad Pack**) has:
+
+- **Pack** — the pack name, e.g. `traefik`, `zot`, or `hello_world`.
+- **Custom registry (optional)** — a git URL. Leave blank for the
+ [community registry](https://github.com/hashicorp/nomad-pack-community-registry);
+ set it to `github.com/Nomploy/nomad-packs` (or your own) and it's added before
+ the run.
+- **Variables (HCL)** — pack variables (`count = 2`, `region = "global"`, …),
+ passed as `--var-file`. Leave empty to use the pack's defaults.
+
+Click **Save**, then **Deploy** to run the pack (`nomad-pack run`). Stop / delete
+tear it down (`nomad-pack destroy`). After a deploy, a **smoke test** reports
+whether the pack's allocations came up healthy.
+
+
+Pack and registry values are validated to a safe character set, since they become
+part of the `nomad-pack` command line.
+
+
+## Using your own registry
+
+Point **Custom registry** at any git repo that holds packs. Nomploy adds it
+(`nomad-pack registry add`) before running, so private/internal pack libraries
+work the same way as the community one.
diff --git a/apps/docs/content/docs/core/overview.mdx b/apps/docs/content/docs/core/overview.mdx
new file mode 100644
index 00000000..b752a725
--- /dev/null
+++ b/apps/docs/content/docs/core/overview.mdx
@@ -0,0 +1,22 @@
+---
+title: Overview
+description: "A single cross-project dashboard that aggregates every service, backup, domain and deployment across your whole organization."
+---
+
+The **Overview** page (sidebar → **Overview**) is a single, organization-wide
+dashboard. Instead of opening each project to see what's running, it aggregates
+the whole org into one view.
+
+## What it shows
+
+- **Services** — every application and compose service across all projects, with
+ its status, so you can spot anything unhealthy at a glance.
+- **Domains** — all configured domains in one list, with their target service and
+ certificate state.
+- **Backups** — scheduled database/volume backups across every project and when
+ each last ran.
+- **Deployments** — recent deployment activity org-wide.
+
+Each row links straight through to the underlying service, domain, backup or
+deployment, so Overview works as a jumping-off point as well as a status board —
+useful once you run more than a couple of projects on the cluster.
diff --git a/apps/docs/content/docs/core/security.mdx b/apps/docs/content/docs/core/security.mdx
new file mode 100644
index 00000000..872eb421
--- /dev/null
+++ b/apps/docs/content/docs/core/security.mdx
@@ -0,0 +1,104 @@
+---
+title: Security (CrowdSec)
+description: "Built-in CrowdSec detects malicious HTTP traffic and blocks offending IPs at the ingress across every load-balancer node — fully local and private."
+---
+
+import { Callout } from "fumadocs-ui/components/callout";
+
+Nomploy ships a built-in **[CrowdSec](https://www.crowdsec.net/)** integration
+that detects malicious HTTP traffic (scanning, probing, backdoor/CVE attempts,
+aggressive crawling) from your Traefik access logs and can **block the offending
+IPs at the ingress** — across every load-balancer node.
+
+Open it from the sidebar (**Security**). It runs fully **locally and privately**:
+no per-request calls leave the cluster, and the CrowdSec Central API is disabled.
+
+## How it works
+
+Two halves — a **brain** (detection) and **bouncers** (enforcement):
+
+- **Brain — CrowdSec engine (LAPI + agent).** A single Nomad job
+ (`nomploy-crowdsec`) runs on the hub. Its agent tails the hub Traefik access
+ log and runs it through the `crowdsecurity/traefik` parsers + scenarios. When an
+ IP trips a scenario (e.g. too many 4xx, known backdoor paths), the engine stores
+ a **decision** (a ban: this IP/range, for a duration) in the **Local API
+ (LAPI)**. Detection alone blocks nothing — it just maintains the ban list.
+- **Bouncers.** The CrowdSec **Traefik plugin** runs as a middleware on every
+ ingress Traefik — the HA pool nodes **and** the hub's standalone Traefik (so all
+ nodes in the LB DNS are covered). In **stream mode** each bouncer pulls the ban
+ list from LAPI every ~60s and caches it; on each request it checks the client IP
+ against the cache — banned → blocked, otherwise passes.
+
+```
+request → Traefik (access log) → agent parses → scenario → ban in LAPI →
+bouncers pull it (~60s) → that IP is blocked on every node
+```
+
+Evaluation is entirely local: rules (collections) are downloaded once at startup,
+then matching runs in-process. The community Central API is off, so **nothing is
+shared** and no community blocklist is pulled — bans come only from your own
+traffic.
+
+## Detection vs enforcement
+
+Two toggles on the Security page:
+
+- **Enable** — run the detector. You see the decisions (would-be bans) it
+ produces, but nothing is blocked. Safe to run on its own to observe.
+- **Enforce** — turn on the bouncers so banned IPs are actually blocked at the
+ load balancer.
+
+## Anti-lockout
+
+The bouncer is given a **trusted-IP allowlist that always bypasses it**:
+
+- the **WireGuard overlay subnet** (`10.10.0.0/24`) is always included, so the
+ cluster, panel and intra-node traffic can never be blocked;
+- plus any **CIDRs/IPs you add** on the Security page.
+
+
+The hub bouncer also covers the panel's own route, so an allowlisted admin IP
+guarantees you keep access. Recovery from a bad enforcement change is to toggle
+**Enforce** off — the panel runs on the hub Traefik, independent of the pool.
+
+
+## Enable it
+
+1. Sidebar → **Security**.
+2. **Enable CrowdSec** → Save. The detector deploys on the hub and starts listing
+ decisions (it auto-enables the hub's file access log it needs).
+3. Add your **admin IP** to the allowlist.
+4. Turn on **Enforce** → Save to start blocking. Use the decisions table to
+ **unban** an IP/range if needed. The **Active bans** chart shows the ban count
+ over time.
+
+## Security & observability alerts
+
+The **Alerts** page (sidebar → **Alerts**) can watch cluster-wide health metrics
+and notify your cluster-alert channels on a threshold breach. Alongside the load
+balancer and per-service metrics, a starter set covers security and observability:
+
+| Metric | Fires when | Typical use |
+|---|---|---|
+| `crowdsec_active_bans` | active bans **>** a threshold | spot an attack wave / runaway bans |
+| `crowdsec_detector_up` | detector **not** running | CrowdSec brain is down |
+| `otel_collector_up` | collector **not** running | telemetry pipeline is down |
+| `log_agent_up` | some node's log agent is down | log shipping gap |
+
+Each metric is cluster-wide (no target) and reports "not applicable" when its
+feature is turned off, so *off* never reads as *down*. The health metrics are
+**debounced** — a breach must hold for the rule's window before it fires, so a
+brief dip during a rolling update doesn't flap the alert.
+
+## Notes
+
+- **Scaling.** The pool bouncer is a Nomad system job, so any new node you tag
+ `nomploy_lb` runs it automatically; the WG subnet in the allowlist covers new
+ nodes' IPs, and all bouncers pull from the one LAPI on the hub. Adding nodes
+ needs nothing extra.
+- **Plugin fetch.** The Traefik bouncer plugin is fetched from
+ `plugins.traefik.io` at Traefik startup. For full offline resilience, vendor it
+ into a custom Traefik image.
+- **Logs source.** Detection currently parses the **hub** Traefik access log; the
+ HA pool ships its access logs to your telemetry backend via OTLP (see
+ [Telemetry](/docs/core/telemetry)).
diff --git a/apps/docs/content/docs/core/self-hosted-registry.mdx b/apps/docs/content/docs/core/self-hosted-registry.mdx
new file mode 100644
index 00000000..ae06f9eb
--- /dev/null
+++ b/apps/docs/content/docs/core/self-hosted-registry.mdx
@@ -0,0 +1,74 @@
+---
+title: Self-hosted registry
+description: "Provision an S3/R2-backed zot registry in one click so build-from-source apps run across a multi-node cluster — or bring your own and register it."
+---
+
+import { Callout } from "fumadocs-ui/components/callout";
+
+On a multi-node cluster, a build-from-source app is built on **one** node but may
+be scheduled on **any** node, so the built image has to be pullable from somewhere
+every node can reach. Nomploy gives you two ways to get there: a **one-click
+self-hosted registry**, or **bring your own** and register it.
+
+## Do you even need one?
+
+Only for **build-from-source on a multi-node cluster**. You can skip a registry
+entirely when:
+
+- **Single-node** — build and run on the same box (the local image store is enough).
+- **You only run pre-built images** (templates like `postgres`, `redis`, or
+ `image: ghcr.io/you/app`) — every node pulls from the public source directly.
+
+## One-click self-hosted (zot + S3/R2)
+
+**Settings → Registry → Self-hosted (zot + S3)** provisions an internal
+[zot](https://zotregistry.dev/) OCI registry whose storage is backed by an
+**S3-compatible bucket** (AWS S3, Cloudflare R2, …). Because the blobs live in
+object storage rather than on one node's disk, the registry is stateless and
+every node can pull from it.
+
+It's fronted by Traefik with a **real TLS certificate** issued via **DNS-01**, so
+there's no per-node `insecure-registries` daemon tweak to make — the cluster
+trusts it out of the box. Build-from-source apps then push here on deploy and run
+cluster-wide.
+
+
+You provide an S3/R2 bucket + credentials and a hostname (with a DNS provider
+configured for DNS-01). Nomploy provisions zot, wires the cert, and registers the
+registry so builds use it automatically.
+
+
+## Bring your own
+
+Any OCI registry works — your own zot, a plain `registry:2`, GHCR, ECR, Docker
+Hub. Run it however you like (a normal Docker service, or the `zot` pack from
+[`Nomploy/nomad-packs`](https://github.com/Nomploy/nomad-packs)) and register it:
+
+**Settings → Registry → Add Registry:**
+
+- **Registry URL** — e.g. `10.10.0.1:5000` or your registry's host
+- **Username / Password** — push credentials (self-hosted registries can be
+ anonymous-pull, so only push needs them)
+- **Image prefix** — e.g. `apps`
+
+Nomploy then pushes to `//` from the control plane, and
+every node pulls it.
+
+## How the cluster trusts + uses it
+
+Two things every node needs:
+
+1. **Trust (TLS).** A registry with a real HTTPS cert (like the one-click option)
+ needs nothing. A plain **HTTP** registry needs its address in each node's
+ Docker `insecure-registries` (`/etc/docker/daemon.json` + `systemctl reload
+ docker`) — a per-node daemon setting with no per-job workaround.
+2. **Auth (credentials).** Handled for you: **Add Registry** publishes the merged
+ docker auth to **Consul KV**, and consul-template on every node renders it to
+ `/root/.docker/config.json` (refreshed when you change a registry). Private
+ images pull cluster-wide **without any credentials in job specs** — the Nomad
+ equivalent of Swarm's `--with-registry-auth`.
+
+
+Consul KV is plaintext, gated only by Consul ACLs — fine for a single-tenant
+private (WireGuard) cluster; use Vault for a shared/multi-tenant one.
+
diff --git a/apps/docs/content/docs/core/telemetry.mdx b/apps/docs/content/docs/core/telemetry.mdx
new file mode 100644
index 00000000..7f8b4fa0
--- /dev/null
+++ b/apps/docs/content/docs/core/telemetry.mdx
@@ -0,0 +1,68 @@
+---
+title: Telemetry (Observability)
+description: "A built-in OpenTelemetry Collector ships cluster metrics and logs to any OTLP backend (SigNoz, Grafana, Datadog, …) with service auto-discovery."
+---
+
+import { Callout } from "fumadocs-ui/components/callout";
+
+Nomploy runs a built-in **OpenTelemetry Collector** that gathers metrics and logs
+from across the cluster and forwards them, in the open **OTLP** format, to any
+backend you point it at — [SigNoz](https://signoz.io/), Grafana/Tempo/Loki,
+Datadog, Honeycomb, or your own collector.
+
+Open it from the sidebar (**Observability**). Nothing is sent anywhere until you
+enable it and set a destination.
+
+## How it works
+
+- **Collector** — a Nomad job runs the OpenTelemetry Collector on the cluster. It
+ **auto-discovers** scrape targets from the **Consul catalog** (every service
+ nomploy registers), so you don't maintain a scrape list by hand.
+- **Export** — everything is forwarded over **OTLP** (gRPC/HTTP) to the endpoint
+ you configure, with an optional **auth profile** (named header/token sets) so
+ you can target managed backends that require an ingestion key.
+- **Per-node log agent** — when log shipping is on, a Nomad **system** job runs one
+ lightweight agent per node that tails each local allocation's stdout/stderr and
+ ships it as OTLP logs (one agent per node, so it can read that node's own logs).
+
+## Signals (toggles)
+
+Turn on only what you want to ship:
+
+- **Service metrics** — per-service CPU/memory/throughput, auto-discovered from
+ Consul.
+- **Nomad metrics** — the Nomad runtime's own telemetry (schedulers, clients,
+ allocations).
+- **Service logs** — application stdout/stderr from every node (via the per-node
+ log agent).
+- **Load-balancer logs** — the HA Traefik pool's access logs, shipped natively
+ over OTLP straight from Traefik.
+
+## Set it up
+
+1. Sidebar → **Observability**.
+2. Set the **OTLP endpoint** of your backend and, if needed, an **auth profile**
+ (e.g. an ingestion key header).
+3. Enable the **signals** you want (metrics, Nomad metrics, service logs, LB logs).
+4. **Save** — the collector (and, for service logs, the per-node agent) deploys
+ automatically and starts forwarding. It self-heals on a panel roll or node loss.
+
+
+If you use SigNoz, nomploy ships importable dashboards for the cluster, services
+and load balancer. Grab the JSON from the project's
+`docs/observability/signoz/` directory and import them in SigNoz.
+
+
+## Alerts
+
+Observability health is alertable from the **Alerts** page: `otel_collector_up`
+fires if the collector stops running, and `log_agent_up` fires if any node's log
+agent is down — so a telemetry gap pages you instead of going unnoticed. See
+[Security & observability alerts](/docs/core/security#security--observability-alerts).
+
+## Notes
+
+- The collector and agents are right-sized automatically; memory per task scales
+ with the number of discovered targets.
+- Because export is plain OTLP, you are never locked to one backend — point it at
+ a different endpoint and redeploy.
diff --git a/apps/website/components/first-features.tsx b/apps/website/components/first-features.tsx
index 78cf5658..7dbd2390 100644
--- a/apps/website/components/first-features.tsx
+++ b/apps/website/components/first-features.tsx
@@ -11,6 +11,7 @@ import {
IconUsers,
} from "@tabler/icons-react";
import {
+ Activity,
Boxes,
Cpu,
Layers,
@@ -18,6 +19,7 @@ import {
Network,
Repeat,
Scaling,
+ ShieldCheck,
UnlockIcon,
} from "lucide-react";
@@ -83,6 +85,18 @@ export function FirstFeaturesSection() {
"Real-time CPU, memory, disk and network graphs across every node, with alerts when your thresholds are crossed.",
icon: ,
},
+ {
+ title: "Built-in Security (CrowdSec)",
+ description:
+ "Detect malicious traffic from your access logs and block offending IPs at every ingress node — fully local, with an anti-lockout allowlist.",
+ icon: ,
+ },
+ {
+ title: "OpenTelemetry Pipeline",
+ description:
+ "Ship cluster metrics and logs to any OTLP backend (SigNoz, Grafana, Datadog) with service auto-discovery from the Consul catalog.",
+ icon: ,
+ },
{
title: "GPU Scheduling",
description: