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.
- 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 host — never on the target.
- 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. - Write the systemd unit and, if the service has secrets, an
EnvironmentFilewith mode0600owned by the service user. - Swap
<release_root>/currentto the new release with an atomic rename,daemon-reload,enable,restart. - Health-check: an HTTP GET, a command on the target, or
systemctl is-active. - 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.
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:latestOr with compose:
NUDO_SECRET_KEY=$(openssl rand -hex 32) docker compose up -dKeep the secret key. It encrypts every stored secret, including your targets' SSH keys. Losing it makes them unrecoverable. The
nudo-statevolume holds the database — without it, everything is lost when the container is replaced.
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.
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.
Needs a recent stable Rust (edition 2024) and protoc.
cargo build --releaseOpen 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_ed25519A 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_abc1233. 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_abc123This 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 --waitThe 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.
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 itor 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.pubssh-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.
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_abc123check 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 defaultIn 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.
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.
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.
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:8080Point 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 --clearA 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 itA 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.
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 --grpcThat 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.
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.
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.
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.
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/Caddyfilenudo renders the config from your services' domains and never touches the host. You apply it however you like.
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.
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.
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.
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. |
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.
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:50051The web tier shares the database and the secret key with the server, for login sessions and the webhook receiver.
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.
make help # every command, with what it does
make check # fmt, clippy, and the full test suite
make demo # nudo + a systemd target + three servicesOr directly:
cargo test --workspace # 862 unit and integration tests
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --checkmake 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 dataEach 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=1The 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 runA 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.
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.
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 it — Discussions 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.
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.