Skip to content

Repository files navigation

nudo

release CI sponsor

A self-hostable control plane for deploying bare-metal binaries. The Coolify experience — dashboard, GitHub CD, live logs, terminal, secrets — with SSH and systemd as the deployment backend instead of Docker.

Some workloads cannot tolerate Docker's network stack overhead or its scheduler jitter: a trading system, a latency-sensitive service. Those run as plain systemd-managed processes on a clean host. nudo gives them the CD and observability story that, until now, only containerised workloads had.

┌──────────────────────────────┐          ┌─────────────────────┐
│  control plane               │   ssh    │  target             │
│                              │ ───────► │                     │
│  nudo-server   gRPC API      │          │  OS + systemd       │
│  nudo-web      dashboard     │          │  your binary        │
│  nudo-mcp      agent tools   │          │  (+ Caddy, if you   │
│  nudo          CLI           │          │   asked for a       │
└──────────────┬───────────────┘          │   domain)           │
               │                          └─────────────────────┘
               │ ssh (optional)
               ▼
┌──────────────────────────────┐
│  build host                  │   Where a build runs, when it
│  clone, build, send the      │   does not run on the control
│  binary back                 │   plane. Never the target.
└──────────────────────────────┘

No agent is installed on a target. Everything — probing, writing units, uploading releases, reading the journal, opening a shell — is an SSH channel opened from the control plane. A target runs the OS, systemd, and the binary you deployed — plus Caddy, if you asked nudo to put a service on a domain.


How a deploy works

  1. Build from a connected GitHub repo, fetch a prebuilt artifact, or take a binary pushed by the CLI. Builds run on the control plane by default, or on a build hostnever on the target.
  2. Upload into <release_root>/.staging-<id>/, verify the transferred size, then move it to <release_root>/releases/<id>/. A truncated upload can never end up somewhere the live symlink could point.
  3. Write the systemd unit and, if the service has secrets, an EnvironmentFile with mode 0600 owned by the service user.
  4. Swap <release_root>/current to the new release with an atomic rename, daemon-reload, enable, restart.
  5. Health-check: an HTTP GET, a command on the target, or systemctl is-active.
  6. If the check fails, put the symlink back and restart again. The previous release is untouched until the new one is proven healthy, so rollback is always available — and it is the same operation as activation.

The unit's ExecStart points at current, never at a release directory, so the unit file does not change between deploys.


Install

Docker

docker run -d --name nudo \
  -p 3000:3000 \
  -e NUDO_SECRET_KEY="$(openssl rand -hex 32)" \
  -e NUDO_BASE_URL="http://localhost:3000" \
  -v nudo-state:/var/lib/nudo \
  ghcr.io/loa212/nudo:latest

Or with compose:

NUDO_SECRET_KEY=$(openssl rand -hex 32) docker compose up -d

Keep the secret key. It encrypts every stored secret, including your targets' SSH keys. Losing it makes them unrecoverable. The nudo-state volume holds the database — without it, everything is lost when the container is replaced.

Coolify

The compose file above works as-is: point a Coolify application at this repository, set the build pack to Docker Compose, and it pulls the published image rather than building the workspace.

Two settings are not optional, and getting either wrong fails in a way that does not look like a configuration problem.

Put the container port in the domain. Under Configuration → General → Domains, write the port you want traffic forwarded to as part of the URL:

https://nudo.example.com:3000

That is not a request to serve port 3000 publicly — Coolify still terminates TLS on 443. Coolify parses the port out of the domain string and only then emits the Traefik label naming the backend port. Without it, the generated config has a router that matches your hostname and no service behind it, so the proxy answers 502 while docker logs shows a container that started and passed its healthcheck. Neither expose nor a published ports mapping substitutes for this, and neither does attaching the container to the proxy network.

Set NUDO_BASE_URL to the public https:// URL, not the default:

NUDO_BASE_URL=https://nudo.example.com

Coolify offers this to you as SERVICE_URL_NUDO. It decides whether the session cookie carries Secure, so left as http://localhost:3000 behind HTTPS the login page renders, the form posts, and the session silently fails to stick. It is also the base for the GitHub webhook URLs, which would otherwise point at localhost.

Leave the nudo-state volume alone — it holds the database and the generated key, and Coolify preserves it across deploys.

Binaries

Download the archive for your platform from the releases page and verify it against its .sha256. The musl build is fully static and runs anywhere.

tar -xzf nudo-v0.1.0-x86_64-unknown-linux-musl.tar.gz
sudo install nudo-*/nudo* /usr/local/bin/

To run the control plane itself as a systemd unit — which is a reasonable thing to want from a tool like this — see packaging/nudo.service. It includes the setup commands and a hardened unit. The packaged unit installs the binaries into a versioned release layout under /var/lib/nudo/self, which is what lets a binary install upgrade itself from the dashboard: the new release is downloaded, verified against the sha256 the release workflow commits into releases.json, staged next to the running one, and swapped in atomically — and reverted by nudo-boot-guard if it cannot start. Nothing is ever fetched and piped to a shell.

Self-upgrading is off until you switch it on in the dashboard's settings — one tick, no configuration. The trade-off of the layout is that the service user can overwrite its own binaries — that is what self-upgrading is — so if you will never use it, installing to /usr/local/bin with a root-owned binary remains the tighter posture, and the /upgrade page keeps printing exact manual commands for it.

From source

Needs a recent stable Rust (edition 2024) and protoc.

cargo build --release

Quickstart: your first deploy

Open http://localhost:3000 and create the first account. Then:

1. Add your SSH key. nudo needs a key that can reach your target. Under Secrets → Add an SSH key, paste the private key — the whole file, including the BEGIN and END lines. It is encrypted immediately and never readable again through the API or the UI. Pasting the .pub half by mistake is refused rather than stored, since that failure would otherwise only surface as an unreachable host much later.

# or from the CLI, which reads from stdin so it stays out of your shell history
nudo secrets set DEPLOY_KEY < ~/.ssh/id_ed25519

A name that is already taken is refused rather than overwritten — a stored value cannot be read back, so replacing one by accident destroys something unrecoverable and leaves nothing to say what was lost. Replacing is still possible, just deliberate: Rotate on the secret's row in the dashboard, or nudo secrets rotate NAME.

2. Add the target. Under Targets → Add, give it a name, a host, the SSH user, and select the key you just stored. Then hit Run checks — it verifies the host key, SSH, sudo, systemd, and a writable release directory separately, so if something is wrong you find out which thing. The first successful connection also pins the target's SSH host key; see Host keys below.

nudo targets add edge-1 --host 10.0.0.5 --user root --ssh-key sec_abc123
nudo targets check tgt_abc123

3. Add the service. Under Services → Add, pick the target, name it, and set what it runs. This is where the latency knobs live — CPUAffinity, Nice, IOSchedulingClass, plus arbitrary extra unit directives. Use View unit to see exactly the file a deploy would write, before writing it.

4. Deploy.

# push a locally built binary
nudo deploy svc_abc123 --artifact ./target/release/bot --wait

# fetch a prebuilt one — a release asset, an S3 object
nudo deploy svc_abc123 --artifact-url https://example.com/bot --wait

# or, for a git-backed service, build from a ref
nudo deploy svc_abc123 --git-ref main --wait

--wait streams progress and exits non-zero if the deploy fails, which is what makes it usable as a CI step.

--artifact serves the file to the control plane over a short-lived loopback listener at an unguessable path, for the length of the deploy — so the binary is streamed rather than staged anywhere, and it needs --wait because this process is what serves it. Use --artifact-url when the control plane is on another host and can fetch the binary itself.

5. Watch it.

nudo services status svc_abc123
nudo logs svc_abc123 --follow
nudo terminal tgt_abc123

Marking a host latency-critical

This is the feature the tool exists for. A target flagged latency_critical refuses every mutating operation unless the request explicitly opts in:

nudo targets add hft-box --host 10.0.0.9 --ssh-key sec_abc --latency-critical

# refused
nudo deploy svc_hft
# error: target hft-box is marked latency-critical; set allow_latency_critical
#        on the request to mutate it

# deliberate
nudo --allow-latency-critical deploy svc_hft --wait

The guardrail applies to everything: the dashboard, the CLI, an MCP agent, and a GitHub push. A push to a branch wired to such a target does not deploy — it is refused and recorded. Refusals are audited, so you can see when an agent tried.

Read-only operations (targets check, logs, services status) are always allowed: the host you most want to inspect should not be the one you cannot. A changed host key is the one exception — see below.


Host keys

nudo pins a target's SSH host key on the first successful connection and verifies it on every connection after that. This matters because nudo holds the private key for each target it manages: without verification, anything that can answer for a target's address gets an authentication attempt with that key.

A mismatch fails closed, before authentication, with both fingerprints:

the host key for 10.0.0.5:22 has changed — refusing to connect.
pinned: SHA256:YDKOP3XHL0…; presented: SHA256:YWnilawiH+…

Unlike the latency-critical guardrail, this blocks read-only operations too — logs and checks included. A mismatch may mean it is not that host at all, and reading logs from the wrong machine is its own problem.

A rebuilt host legitimately has a new key. Review it and accept it:

nudo targets host-key tgt_abc123                              # show what changed
nudo targets host-key tgt_abc123 --accept SHA256:YWnilawiH+…  # accept it

or from the target's page in the dashboard, which shows both fingerprints and the key itself. Acceptances are audited with the fingerprint and who accepted it.

Verify on the machine itself before accepting, over a console or some channel that does not depend on that address being the right host:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

ssh-keyscan asks the same address nudo is being redirected away from, so it confirms nothing on its own.

For a host rebuilt while nobody was watching, whose old key is of no further interest, nudo targets host-key <id> --forget reopens the first-use window. It is the weaker option — prefer --accept whenever there is a fingerprint to compare against.

Targets that existed before this was added have no pinned key, so their next connection records one rather than refusing. Upgrading does not break a working fleet.


Build hosts

Builds run on the control plane by default, and for most instances that is the right answer. But the control plane is often the smallest machine in the deployment — a dashboard, a SQLite file and an SSH client, comfortable on 1 vCPU — and pointing a service at a Rust repo makes that box run cargo build --release. A build host is somewhere else to run it.

A build host is not a deploy target. A target runs the OS, systemd and the binary you deployed; nothing is installed or supervised on a build host, and nothing is ever deployed to one. They are separate things with separate commands, and pointing a service's build at its own deploy target is not possible.

nudo build-hosts add builder-1 --host 10.0.0.9 --user build --ssh-key sec_abc123
nudo build-hosts check bh_abc123

check verifies each prerequisite separately, as targets check does: the host key, SSH, a writable workspace, and git. Not sudo, and not systemd — a build host needs neither.

Then say where a service builds. A service's own setting always wins; without one it follows the instance default:

nudo build-hosts default bh_abc123   # everything unpinned builds here
nudo build-hosts default --local     # ...back on the control plane
nudo build-hosts default             # show the current default

In the dashboard, a service's Build on field offers the instance default, the control plane, or a named build host. Pinning a service to the control plane is not the same as leaving it unset: a pinned service stays there when the instance default later changes.

The deploy log is identical wherever a build ran — same lines, same order, same secret redaction — so nothing downstream has to care.

An instance that upgrades and configures nothing keeps building exactly where it built before. The local path is unchanged and remains the default.

What a build host is not

It is not a sandbox. Builds on one host are not isolated from each other, and nudo does not try to isolate them. A build command is arbitrary code; two mutually distrusting builds on one machine can see each other. If that matters, run the host so it cannot happen — a one-shot container, an ephemeral VM, a fresh instance per build. That is an operational decision, and nudo does not make it for you.

Credentials do reach the build host. nudo clones there rather than transferring a tree from the control plane, so the host needs access to the repository: a deploy key is written to a 0600 file for the clone's lifetime and removed with the workspace, and an App token is passed on the command line and redacted from any output. Register a build host as deliberately as you would a target — its SSH host key is pinned and verified on exactly the same terms, for exactly this reason.

A build workspace is temporary. Each build gets a fresh directory under the host's workspace root, removed when the build finishes however it finishes. There is no build cache and no shared artifact store; each build is independent.

Latency-critical build hosts

A build host can be marked latency-critical, and building on one is allowed — you may have exactly one spare machine. It is not silent: the dashboard, the CLI and check all say that a build here will contend with whatever else runs on the box for CPU, cache and memory bandwidth, and mutating it needs --allow-latency-critical like any other latency-critical host.

Expect jitter on anything sensitive while a build is in flight. If that is not acceptable, that machine should not be a build host.


Custom domains and HTTPS

By default a deployed service is reached by IP and port, and putting a domain and a certificate in front of it is your problem. Ingress makes it nudo's: give a service a domain, and it is served over HTTPS with a valid certificate.

nudo manages Caddy as a systemd unit on the target — installed, configured and reloaded the same way it manages anything else on a host. Caddy because its automatic HTTPS is a default rather than a configuration, which means nudo implements no ACME and never handles a certificate or its private key. Certificates are Caddy's problem entirely.

Turn it on for a target, then give a service a domain and the port it listens on:

nudo targets ingress enable tgt_abc123 --acme-email ops@example.com
nudo services domain svc_abc123 --route api.example.com:8080

Point an A record at the target and that is the whole of it. The certificate is issued on the first request to the domain.

A service can answer on several addresses — routes are replaced by what you pass, not added to:

# an apex and its www, both reaching the same port
nudo services domain svc_abc123 --route example.com:8080 \
                                --route www.example.com:8080

# a path under a domain whose root is served by something else
nudo services domain svc_api --route example.com/api:9090

# stop routing to it
nudo services domain svc_abc123 --clear

A path is stripped before the request reaches the service: routed at /api, it sees /users rather than /api/users. A service that needs the prefix intact should be routed at the domain root instead.

nudo targets ingress check tgt_abc123    # is the proxy up, does DNS point here
nudo targets ingress show tgt_abc123     # the config nudo would write
nudo targets ingress reload tgt_abc123   # re-apply it

A deploy re-renders its target's routes on the connection it already has, so a domain follows its service without a separate step. Changing a route reloads immediately rather than waiting for the next deploy.

gRPC

A gRPC server needs HTTP/2 all the way through. A proxy that terminates HTTP/2 at the edge and talks HTTP/1.1 to the backend breaks every call — so nudo has to be told, rather than guessing:

nudo services domain svc_grpc --route grpc.example.com:50051 --grpc

That renders reverse_proxy h2c://127.0.0.1:50051, which is cleartext HTTP/2 to the service and TLS at the edge. The dashboard's routing card has the same checkbox, and marks such routes gRPC in the route table.

Worth knowing if you are coming from Coolify: it has no equivalent. Its label generation emits HTTP routers only — no h2c, no TCP or UDP routers — so a gRPC service behind Coolify is reached over HTTP/1.1.

Raw TCP and UDP are not supported. Caddy can do it with the layer4 plugin, which is not in the standard binary; a service that is neither HTTP nor gRPC still needs its port reached directly.

DNS is the thing that goes wrong

A domain whose record does not point at the target cannot be issued a certificate, and Caddy will retry indefinitely without saying so anywhere obvious. ingress check diagnoses it explicitly:

ok   installed                caddy 2.7.6
ok   running                  caddy.service is active
ok   admin_api                answering on 127.0.0.1:2019
ok   config                   the config on the host is what nudo would write
FAIL api.example.com          does not resolve

note: api.example.com does not resolve yet. Create an A or AAAA record pointing
      it at this host, or a certificate cannot be issued.

The lookup runs from the target rather than from the control plane, because what matters for issuance is what that host and the world see. A domain resolving somewhere that is not this host is reported as a note rather than a failure — that is what a CDN or a load balancer in front looks like, and nudo cannot tell the difference from here.

If a reload fails

The config is written to a temporary path and validated before it replaces the live one, so a typo in a domain is caught while the proxy is still serving what it was serving. If it is rejected anyway, Caddy keeps the previous config: the site stays up, the target is marked degraded, and the reason is on the target's page.

That is worth knowing because a degraded proxy is easy to miss — the site works, it is just serving the routes from before your change. A deploy reports it and carries on rather than failing: the service is up and healthy, and only the routing did not take.

Ports

Once services declare ports, nudo can see that two on the same host both want 8080, and it refuses the second with a message naming the first. It does not assign ports — that stays your choice. The same port on two different targets is fine.

Bringing your own proxy

If you already run nginx or Traefik and are not giving it up, use external mode:

nudo targets ingress enable tgt_abc123 --mode external
nudo targets ingress show tgt_abc123 > /etc/caddy/Caddyfile

nudo renders the config from your services' domains and never touches the host. You apply it however you like.

Ingress on a latency-critical host

Refused without --allow-latency-critical, like every other mutation of such a host. A reverse proxy adds a hop to every request and a process competing for CPU and cache; on a box tuned for latency that may be exactly wrong. It is allowed if you say so explicitly.


GitHub CD

Under Sources, create a GitHub App. nudo generates the manifest, hands your browser off to GitHub, and receives the App id, private key and webhook secret at the callback. Then install the App on your repositories.

Point a service at a repo and branch, tick auto-deploy on push, and a push builds and deploys it. Deploy outcomes are written back as commit statuses under the nudo/deploy context, so they show up on the commit and in branch protection.

A push whose every commit message contains [skip ci] or [skip cd] does not deploy; one real commit among them does.

Webhook signatures are verified with HMAC-SHA256 over the raw request body, with a timing-safe comparison, in every environment. There is no bypass.

For repositories without an App, add a deploy-key source instead and paste the public half into GitHub.


Agents (MCP)

nudo-mcp speaks MCP over stdio and exposes nine coarse-grained tools: list_targets, list_build_hosts, list_services, get_unit_status, deploy, rollback, stream_logs, run_command, list_deployments.

{
  "mcpServers": {
    "nudo": {
      "command": "nudo-mcp",
      "env": {
        "NUDO_ENDPOINT": "http://127.0.0.1:50051",
        "NUDO_AGENT_LABEL": "claude"
      }
    }
  }
}

deploy, rollback and run_command default to dry_run: true, so a mistaken call describes a plan instead of acting. Every call is attributed to the agent session in the audit log.

There is no tool to create or edit targets, services or secrets, and no interactive shell — those are left to a human. run_command (one-shot, captured) is the shape an agent can actually use.


Configuration

Every setting is a flag or an environment variable. The flags are --kebab-case versions of the names below.

Variable Default What it does
NUDO_SECRET_KEY generated 32-byte AES-256-GCM key, hex or base64. Set this and keep it. If unset, one is generated into the data directory and you are warned.
NUDO_SECRET_KEY_FILE Read the key from a file instead. Preferred: it keeps the key out of the process environment.
NUDO_DB nudo.db The SQLite file. Created, with migrations applied, on startup.
NUDO_DATA_DIR ./data Build workspaces, uploads, and the generated key.
NUDO_BASE_URL http://localhost:3000 This instance's public URL. Decides whether the session cookie is marked Secure, and is what GitHub is told to call. Set it to your real https:// URL behind a proxy.
NUDO_WEB_ADDR 127.0.0.1:3000 Where the dashboard listens.
NUDO_GRPC_ADDR 127.0.0.1:50051 Where the gRPC API listens. Loopback by default.
NUDO_ENDPOINT http://127.0.0.1:50051 Where clients (CLI, MCP, dashboard) find the API.
NUDO_TOKEN An API token. Used by the CLI, and by the dashboard when the API requires one.
NUDO_REQUIRE_API_TOKEN false Require a valid API token on every gRPC call. Off by default because the API binds to loopback with the dashboard in front of it; turn it on when anything else can reach it. The all-in-one provisions a token for its own dashboard, so enabling it cannot lock you out.
NUDO_LOG_BUFFER 2000 Log lines retained per service, so a freshly opened log view is not empty.
NUDO_PROBE_INTERVAL 60 Seconds between target reachability probes. 0 disables.
NUDO_ALLOW_SETUP true Whether first-run setup may create the initial admin. Closes automatically once an account exists.
NUDO_CHECK_FOR_UPDATES true Fetch the release manifest and show a banner when a newer version exists. The only request nudo makes on its own behalf, and it sends nothing about your instance. Can also be turned off from Settings → This instance.
NUDO_UPDATE_MANIFEST_URL the repo's releases.json Where the manifest is fetched from. Point it at your own copy to check against an internal mirror.
NUDO_UPDATE_INTERVAL_HOURS 24 Hours between checks.
RUST_LOG info Log filter.

Per-service configuration

Set on the service, in the dashboard or over the API:

  • Artifact source — a URL, a git repo + branch + build command + artifact path, or a CLI upload.
  • Build host — where a git build runs: the instance default, the control plane, or a named build host. Never the deploy target.
  • Domain and port — where the service is reachable from outside, on targets with ingress enabled. Both or neither.
  • Unit — description, exec args, working directory, user, group, restart policy, ordering, and the latency knobs (CPUAffinity, Nice, IOSchedulingClass) plus arbitrary extra directives written verbatim.
  • Health check — HTTP URL, command, or systemctl is-active, with timeout, retries and initial delay.
  • release_root — defaults to /opt/<name>.
  • keep_releases — retained releases available for rollback. Default 5.
  • Secrets and env — secret ids resolved at deploy time into the EnvironmentFile; non-secret env inlined into the unit.

Running the halves separately

nudo-all-in-one runs the control plane and dashboard in one process, which is what most people want. They can also run apart — the dashboard is a gRPC client, so it does not care where the API is:

nudo-server --grpc-addr 0.0.0.0:50051 --database /var/lib/nudo/nudo.db
nudo-web    --addr 0.0.0.0:3000 --grpc-endpoint http://control-plane:50051

The web tier shares the database and the secret key with the server, for login sessions and the webhook receiver.


Updates and telemetry

nudo checks for new releases by fetching a static JSON file (releases.json in this repository) and comparing versions. It sends nothing — not your version, not an identifier, not an install count. There is no telemetry in this codebase and no setting to disable it, because there is nothing to disable. A test asserts that the module has no network client, so that stays true.

Nothing is ever installed automatically. The banner links to Upgrading nudo (/upgrade), which prints the exact commands for the way this instance is actually installed — detected, not configured, so a container is told to pull an image and a host install is told to verify a checksum and replace binaries. Neither is shown the other's instructions.

Upgrading replaces executables and touches nothing else: the database, the data directory and your configuration all live outside them, and schema changes are applied automatically the first time the new version opens the database. The page says this, along with the one real trap — if you never set NUDO_SECRET_KEY, the generated key lives in the data directory, and every stored secret is unreadable without it.

This is the one place nudo deliberately does less than the tools it borrowed from: an updater that downloads a script and runs it as root is a large amount of trust to place in a URL, for a tool that holds every target's SSH keys. A test asserts the upgrade page has no form and pipes nothing into a shell.

Release notes are rendered in-app under What's new, from the manifest the last check recorded — so an instance that has lost network access still shows the notes for the release it knows about. Both the check and the occasional "support this project" note can be turned off in Settings → This instance.


Development

make help                       # every command, with what it does
make check                      # fmt, clippy, and the full test suite
make demo                       # nudo + a systemd target + three services

Or directly:

cargo test --workspace          # 862 unit and integration tests
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check

The demo

make demo starts two containers — nudo, and a Debian host running systemd as PID 1 — registers the second as a target over SSH, and deploys the example services in examples/services/. That second container is the point: without a real systemd host you can look at the dashboard but not actually deploy anything.

make demo                       # the whole thing, from nothing
make demo-open                  # the URL and the demo credentials
make example-break              # ship a broken release and watch it roll back
make example-unit EXAMPLE=latency-critical   # the unit file a deploy writes
make demo-units                 # what systemd actually applied on the target
make demo-changelog             # seed a pretend release, to see the update banner
make demo-down                  # stop it; demo-clean also deletes the data

Each example under examples/services/ is a run.sh and a service.json, and exercises something specific: hello-http an HTTP health check, latency-critical every scheduling knob, and flaky a service that starts but never becomes ready — which is what makes the rollback demo real rather than simulated.

The end-to-end tests need Docker. They start a systemd-enabled container, install an SSH key into it, and deploy a real artifact through the real engine — then make a health check fail and assert the rollback restored a working service. The build-host half of the suite clones from a git repository seeded inside the container, builds there, and asserts the binary reached the target, that the deploy log does not say where the build ran, and that the workspace is gone afterwards — on the failing path as well as the succeeding one. It also pushes a second commit whose build produces a service that never becomes ready, and asserts the rollback restored the previously built release and that it is serving again.

The ingress tests install a real Caddy in the container, render the config from a service's domain, start it, and confirm a request to that domain reaches the service — Caddy accepting what nudo renders is not something a unit test can establish. They also assert a hand-edited config is replaced by the rendered one, and that a domain which does not resolve produces a warning rather than a failed check.

cargo test -p nudo-server --features e2e --test e2e -- --test-threads=1

Looking at the dashboard

The dashboard is server-rendered HTML with no component library and no story book, so the only way to see what a page looks like is to run it. make screenshots renders every view into screenshots/ — twice, because the two states fail differently:

  • empty, a fresh instance with nothing configured, which is what a new operator sees first and where a missing empty state reads as a broken page
  • populated, with targets, build hosts, services and secrets, including the states that only appear when something is wrong — a latency-critical host, a build host that is the instance default, a service pinned to one

Light only by default — the two themes differ in palette rather than layout, so capturing both doubles what there is to look through for little. --theme dark or --theme both when the palette is what you are checking.

make screenshots                        # everything: 33 images
make screenshots ARGS="--only build"    # just the build-host views
make screenshots ARGS="--theme both"    # light and dark
make screenshots ARGS="--keep"          # leave the instance up to poke at
scripts/screenshots.py --url http://localhost:3000   # an instance you already run

A full run replaces the directory. A filtered one — anything with --only, or a single --state or --theme — overwrites only what it captures and leaves the rest, so iterating on one page does not throw away the set you were comparing against. It reports N captured, M total when the two differ.

Needs Docker and Google Chrome. The output is gitignored: it is for looking at, not for committing.

Layout

controlplane.proto        the API contract — authoritative, unmodified
crates/proto/             generated server and client stubs
crates/format/            shared operator-facing vocabulary and formatting
crates/server/            gRPC services, deploy engine, SSH executor, SQLite
crates/web/               dashboard: axum + maud + htmx, a gRPC client
crates/cli/               nudo — a gRPC client, no duplicated logic
crates/mcp/               MCP server for agents
crates/allinone/          server + dashboard in one process
packaging/nudo.service    run the control plane itself under systemd
examples/services/        demo services, one directory each
examples/scripts/         what `make demo` runs
releases.json             the release manifest a running instance fetches
scripts/add-release.py    adds an entry to it; run by the release workflow
cliff.toml                changelog generation from the commit log

CHANGES.md records the design decisions, the bugs testing found, and what is deliberately out of scope.


Supporting nudo

nudo is free and built by one person. If it is saving you the cost of a platform, sponsoring keeps it maintained.

If money is not on the table, these help as much:

  • Star the repository — it is most of how anyone finds a project like this
  • Report what breaks — a bug report with the version, the target's OS and what you expected is worth more than a vague one, and more than silence
  • Say what you deployed with itDiscussions is the place; knowing what people actually run decides what gets built next

The dashboard mentions this too, at most once a month and never before you have deployed something. It can be turned off for good in Settings → This instance, and there is no telemetry to turn off, because there is none.


License

MIT. Portions of the GitHub integration and the terminal design are ported from Coolify (Apache 2.0) — see NOTICE for what was taken and where. Vendored front-end assets keep their own licenses; see crates/web/src/assets/README.md.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages