Skip to content
Pages in Project

Contributing to Noust

The four rules, the architecture and how to work on Noust.

Guide Updated View as Markdown

Noust is a Python 3.10+ CLI for deploying web apps on Linux servers. Automates Nginx/Apache, SSL, systemd, databases and backups; builds every deploy as a health-gated release with instant rollback; and serves an optional browser console (React SPA) over a JSON API.

Repository: https://github.com/Perkybeet/noust | License: AGPL-3.0-or-later (from 2.1.0; releases up to 2.0.x were non-commercial source-available)

The licence of a contribution

Noust is free software under the AGPL-3.0-or-later. By contributing you agree that:

  1. your contribution is licensed under the AGPL-3.0-or-later, like the rest of the project;
  2. you have the right to submit it, and you say in its description where any part of it comes from when it is not your own, with its licence;
  3. you also grant the author, Yago López Prado, a perpetual, irrevocable licence to use your contribution under other terms, so that Noust can also be offered under a separate commercial licence alongside the AGPL, which stays.

For a licence on other terms, write to support@noust.dev.


The four rules

These are not style preferences. Each one exists because its absence produced a specific class of defect that shipped to users. Breaking one is how the project regresses.

1. Nothing runs a process except CommandRunner

src/noust/core/runner.py is the only module allowed to import subprocess. Everything that calls nginx, systemctl, certbot, git, npm or a database client goes through it.

Python
result = self.runner.run(["systemctl", "restart", unit], timeout=30, check=True)
self.runner.stream(["npm", "install"], on_line=logger.substep, timeout=900)
self.runner.capture_to_file(["pg_dump", db], destination, compress=True)

Argv only, never a shell. Timeouts are mandatory. Secrets go through env= or input=, never argv, because everything on a command line is visible in ps to every local user.

tests/conftest.py makes real process execution fail in every test, so code that bypasses the runner cannot be tested and fails loudly instead. tests/test_architecture.py enforces the import rule.

--dry-run is implemented here too, as DryRunRunner, and for file changes as DryRunFileSystem in core/fs.py. It is not wired per command: that is what left the flag honoured in three code paths and silently ignored in every destructive one.

2. except Exception is only allowed at an error boundary, and must log

There were 302 of them, 149 silent. That is the mechanism by which five calls to methods that did not exist shipped for entire releases: every AttributeError became a cosmetic warning. Catch the specific exception. If you genuinely need a broad catch, it belongs in the CLI or API error boundary and it logs.

mypy is the guard for this class of bug and it blocks CI.

3. There is one implementation of each thing

The version lived in six hand-synchronised files, app-type detection had four implementations with contradicting precedence, and the web API was a second implementation of the whole product. When you find yourself writing something that already exists, use the existing one or move it somewhere both callers can reach.

The web layer in particular is a client of the managers, never a parallel implementation. An endpoint translates HTTP to a manager call and back.

4. Guards go at the chokepoint

A rule enforced in the caller is a rule with as many holes as there are callers. Unit ownership is checked in ServiceManager, not in the endpoints that use it. Read-only SQL is enforced by the database server, not by a keyword allowlist. Escaping is done by the template engine, not by remembering.


Architecture

src/noust/
  core/
    runner.py       the only place processes are executed
    fs.py           the seam every file change goes through (DryRunFileSystem under --dry-run)
    paths.py        every system path, unit name and marker in one place
    retired_names.py  4.0 refuses a server whose data 3.x never moved (the paths of before 3.0 are
                    spelled here and nowhere else), and retires the links 3.x left
                    (`noust setup retire-links`)
    legacy_names.py every name from before 4.0 that is still accepted (a header a script sends, a
                    token in a CI secret, a file in a repository), each with why and until when;
                    code asks it for the name, it never spells one
    legacy_archive.py  the one reader of the backups and exports made before 4.0
    appdir/         how an application's directory is laid out, below the managers and the
                    deployers that both read it: releases.py (ReleaseManager: the only code that
                    knows releases/, current, shared/), layout.py (which layout, where .env
                    lives) and permissions.py (handing files to the app's account)
    deploy_events.py  the deployment events every deployment publishes and its subscribers
    store.py        SQLite persistence (WAL) with versioned migrations
    schema_fragments.py  what a schema version written one fragment per area shares: apply it, and
                    ask whether a store lacks any of it (v14 and v16, 4.1's, use it; each lane of a
                    release fills its own `<AREA>_SQL` and `<AREA>_COLUMNS` and never another's)
    config.py       layered config; secrets written 0600, redacted on the way out
    exceptions.py   NoustError hierarchy, used for real
    channel_secrets.py  the notification channels' credentials in config.yaml, sealed with
                    at_rest.py under a key beside the file
    cdn_networks.py the networks of Fastly, Bunny and ArvanCloud, so the DNS check names the CDN
    sealing.py      a central's secrets at rest: scrypt, then AES-256-CBC and HMAC-SHA256
                    through openssl, never a new dependency
    store_secrets.py  which columns of the store hold a secret value and are emptied in a copy
                    (an incident's snapshot, `store export`); a test fails on a column nobody ruled on
    compose_yaml.py  a Compose document read the way Compose reads it (PyYAML takes `2222:22` for a
                    base 60 number and `no` for false); every reader of a Compose file uses it
    logsafe.py      every log record of the process reports its message on one line, so a value that
                    came from a request cannot start another line (installed by importing logger.py)
    templates.py    the one Jinja environment unit files, sites and pools are rendered with
    outbound.py     where a request may go (`require_public_host`) and what its path may be
                    (`path_segment`, `api_path`)
    timeutil.py     the one clock: `utc_now_iso` (to the second or with its fraction, as each
                    column was written), `local_now_iso`, `utc_now`; no module keeps a `_now`
    app_lookup.py   "is there an application here" for what runs as the instance (the console's
                    scoped answer is `web/api/deps.py` `known_app`); the command line's is
                    `cli/lookup.py`
    web_gate.py     the one door to the console's token manager for the layers that cannot import
                    `web`, with the error that says how to install its dependencies
  validators/       names, environments, sources, ports, domains
  managers/         adapters: web server, systemd, certs, backups, databases, source, cron
    diagnose.py     why an app is down: read-only probes, most likely cause first
    health.py       the server-wide report behind `noust health` and /api/system/health
    siteconf/       the one nginx/Apache parser: lossless tree, structured model, edit
                    operations, request routing (rule 3: nothing reads a site with a regex)
    stack_databases.py  a Compose stack's databases: detect, dump, restore
    name_migration.py  what a server still carries from before 4.0, and `noust setup rename`, which
                    finishes the renaming without restarting anything (upstream_names.py is its
                    nginx half)
    database/managed.py  the engines Noust runs itself in containers beside the host's (another
                    version, or an engine the host lacks): make, start, stop, upgrade, remove, from
                    the catalog of official images pinned by digest (instance_catalog.py); once
                    made, an instance is one more key (`postgresql@pg16`) to every database manager
    port_claims.py  who holds a port: applications, the ports a Compose stack publishes, streams,
                    databases, engines, SSH, the console and the kernel; every caller asks it
    app_identity.py the one answer to "which account does this app run as"
    site_topology.py, timeline.py   what a site reaches, live; one timeline over a stretch
    internal_ca.py, ca_trust.py  the server's own authority for names that only resolve inside a
                    private network (`noust cert ca`): name-constrained, one machinery with the
                    database engines' (`TlsAuthority`), its leaves kept as uploads, trust told per device
    console_exposure.py  the console on a public name over TLS (`noust console expose`): nginx
                    site, certificate, trusted proxy, and a record of exactly what it made so
                    `unexpose` removes that and no more
    console_service.py  the console as a process Noust starts and stops: the daemon, the unit
                    `noust web enable` writes, the port it holds, the trusted proxies; the commands
                    of `cli/commands/web.py` and the layers that look at the running console ask it
    backup_manager.py, rollback_manager.py   a backup, and going back to one (the rollback
                    points, and the safety backup taken first)
    server/platforms.py  the one table of releases and processors Noust installs on; the
                    installer and docs/PLATFORMS.md carry generated copies of it
  deployers/        strategies over a declarative pipeline (base/: `BaseDeployer`, a chain of classes, one module
                    per phase of the pipeline), one per app type
    lifecycle.py    the one "update an app"; release activation; resource limits
    migrate.py      in-place to releases, explicit only, undone exactly on failure
    domains.py      the one "names an app answers on": store, site, certificate, DNS check
    compose_relay.py  zero-downtime for Compose: a twin of each web service while it is recreated
    compose_adopt.py  `noust app adopt`: register a stack that already runs, touching nothing
    container.py    `--type container`: a Dockerfile, a registry image or Railpack, run as a compose
                    file Noust writes (helpers/container_compose.py), one image per release
                    (helpers/container_image.py builds, pulls and prunes them), settings in
                    core/container_store.py, changed through helpers/container_settings.py
    helpers/        health_gate.py, release_build.py,
                    project_file.py (noust.yaml), hooks.py (which hooks run), site.py (the one
                    writer and deleter of an app's site), compose_ports.py (the one port reader)
  fleet/            node enrollment: authorize.py installs the restricted key and prints the
                    join code; keys.py the per-node keypair; tunnels.py the SSH tunnels
                    (through CommandRunner); client.py a node's API over its own tunnel;
                    nodes.py the store-backed registry a central keeps of them; enroll.py the
                    guided enrollment from the central (a credential used once, never kept)
  central/          what a central is: role() (hub or server), setup.py (data directory, TLS,
                    the allow-list), unlock.py the socket a sealed central takes a passphrase on
  monitor/          what watches the server: the collector and its samples, alert rules and the
                    notifiers they reach, the process monitor and the reachability probe that
                    `noust-monitor` runs
  recipes/          well-known applications as data, never code (`<name>.yaml` and the assets they
                    render or copy): the schema, the catalog, the Coolify and Dokploy importers
                    and the deploy of a recipe
  integrations/     services outside the server: code hosts (forges/, github/, gitlab/) and the
                    webhooks a registry or a forge calls
  terminal/         the console's shell in the browser and its one-off commands: tickets, sessions,
                    recordings, and the limits around the most root-equivalent thing the console opens
  manifest/         a server written down: schema, plan and apply, as a client of the API (rule 3)
  pushdeploy/       a push as a way to deploy: a bare repository per application and who may push
  client/           Noust as a client of another Noust server, from a laptop or a pipeline, with a
                    token and no SSH: the API, contexts and jobs
  templates/, completions/   data and no Python: the Jinja templates of nginx, Apache, PHP-FPM and
                    systemd, and the generated shell completions
  web/
    api/            thin layer over the managers; one error contract for every router;
                    node_proxy.py forwards every node call, event and WebSocket over its
                    tunnel (rule 3: a central re-implements nothing a node does)
    auth/           the console's sign-in, a package of the pieces `web/auth.py` was made of
                    (`tokens.py` the token manager, `sessions.py` the SQLite store, `ratelimit.py`,
                    `fleet.py` what a node admits from its central, `dependencies.py` what every
                    route resolves); `__init__.py` imports each public name it had
    server.py       security middleware, CSP, serves the console
    versioning.py   /api/v1 as /api, X-Noust-API-Version, deprecated(); the compatibility
                    promise is docs/api.md and scripts/api_compat.py (panel/openapi.baseline.json)
    events.py       the /events SSE stream the console listens to
    jobs.py         background jobs, persisted with their logs
    job_reconcile.py  a job in a transient systemd unit is followed again after a restart
    gateway_client.py  the console's own API called back as whoever asked (the manifest's plan
                    and apply run on it): the engine is a client, never a second implementation
    manifest_jobs.py  applying a manifest as a job, outside the deploy queue it feeds
    static/         the console's committed Vite build (generated from panel/, never edited)
  cli/              Click tree (app.py, commands/, including fleet.py, node.py, central.py);
                    handlers hold no business logic
panel/              Noust console source: React 19, TypeScript, Vite, TanStack Router/Query,
                    Base UI, Tailwind v4; src/api/schema.gen.ts is generated from openapi.json
  e2e/              Playwright + axe + CSP gate against the real backend
scripts/console_server.py   the real API on a seeded, sandboxed store (development and E2E): the launcher of the
                            package `scripts/console_server/`, which holds the sandbox, the machine the runner
                            answers for, the seed of each page, the fleet and the Server area, one module each
scripts/generate_sdk.py     sdk/python from panel/openapi.json; sdk/terraform is the map the provider is
                            written from (docs/sdk.md, docs/terraform.md); CI fails on a stale file
terraform-provider-noust/   the Terraform and OpenTofu provider (Go, Plugin Framework): a client of the API
                            like the SDK, with the operations table (internal/noust/operations.go) held to
                            panel/openapi.json by its tests. Published from a repository of its own
                            (docs/terraform-registry.md, scripts/prepare_terraform_registry.py)
scripts/reproducible_build.py   the wheel and the sdist of a commit, byte for byte
                            (docs/reproducible-builds.md); CI and the release workflow build with it
tests/support/              what the tests share and is not a test: simulated machines, factories, builders
                            and the helpers of test modules (a test module is never imported by another,
                            and nothing but `test_*.py`, `conftest.py` and `__init__.py` sits loose in
                            `tests/`; `tests/test_tests_layout.py` holds both)
tests/integration/run.py    real deploys in a systemd container (Docker); not part of pytest. The 4.0
                            features' scenarios (how an application arrives: Procfile, scheduled updates,
                            push, uploads, previews; and what protects its data: S3 backups, database
                            instances, restoring a server) are in scenarios_delivery_and_data.py (loaded
                            by run.py), and
                            `--upgrade-from-3.3` (scenarios_upgrade.py) upgrades a 3.3.1 server that
                            carries every legacy name; `--downgrade-to-3.3` goes back over a store the
                            new version migrated. scenarios_real.py holds the ones that run the real
                            software: the Compose recipes (`--scenario recipe_compose`) and a server a
                            real Coolify runs, taken over (`--scenario coolify_takeover`), both opt-in
                            because they pull gigabytes. Everything it creates on the machine's Docker
                            carries the label `noust.test=1`; the rest runs in the harness container's
                            own Docker (NOUST_IT_IMAGE, NOUST_IT_DOCKER_VOLUME and NOUST_IT_PREFIX name the
                            image, the volume and the containers when the Docker is shared)
tests/integration/install_matrix.py   the installer on one container per release (run through
                            `run.py --install-matrix`, inside the harness container); not part of pytest
tests/integration/terraform_run.py    the provider and the Python SDK against the live API of
                            scripts/console_server.py, Go and Terraform in containers; not part of pytest
tests/integration/identity.py         SAML, OpenID Connect, LDAP and SCIM against Keycloak, OpenLDAP and a
                            SCIM conformance client; not part of pytest
tests/integration/network_run.py      fail2ban, HTTP/3, forward-auth, prefix routes, a site's history,
                            Coolify templates and the private certificate authority against the real
                            programs and real clients (Debian server, Alpine client, AlmaLinux device);
                            nginx with ModSecurity and the Core Rule Set blocking real attacks; certbot
                            against Pebble (HTTP-01, DNS-01 through Noust's hooks and through the RFC 2136
                            plugin on a real BIND, and a wildcard); the time from `apt install` to a first
                            deploy over HTTPS, measured and kept in tests/integration/results/first-deploy.json;
                            not part of pytest
tests/integration/fleet_run.py        a central and a node over real SSH: enrollment, the restricted key,
                            a server reached through a bastion and one that dials in (`fleet link`);
                            not part of pytest
tests/integration/toolchains_run.py   the JVM, .NET, Elixir, Deno, Go, Rust, Rails and the Node frameworks
                            (Nuxt, SvelteKit, Astro, Remix, Angular) built and served, and the runtimes
                            downloaded and installed (Node, Python with uv, PHP with apt); not part of pytest
tests/integration/forge_run.py        a real Gitea and a real Forgejo: a deployment's commit status, a
                            preview's pull request comment and the signed webhooks the receiver reads
                            (docs/sources.md); one loopback port, `noust-test-w6x-*`, not part of pytest
packaging/install.sh        the one-line installer (POSIX sh, shellcheck-clean), published with a
                            signed SHA256SUMS; its platform table is generated, never edited

Layers

Imports go down, never up. core and validators are the base; managers, deployers, fleet, monitor and pushdeploy do the work on top of it; web and cli are the two ways in, and nothing under them imports them. tests/test_layering.py holds the rules, each with the list of places that still break it: the test fails when a new place appears and when a listed one is fixed and not taken off, so the lists can only get shorter. A command attaches itself to its group by importing the group (a module of its own), never the other way round, so no module imports another that imports it back when it loads. The graph is read from the syntax tree (tests/support/import_graph_support.py); an import under TYPE_CHECKING is not one at run time and does not count.

Layer Must not Places that still do
core, validators import central, cli, deployers, fleet, integrations, managers, monitor, terminal, web 28
deployers, fleet, managers, monitor, pushdeploy import cli, web 11
web import cli 0
any module import a module that imports it back when it loads 0
any module pass 3000 lines 17

The table is written from the test's own lists. A new module past 3000 lines is split before it is written, and the ones that are past it do not grow: new code goes in a module of its own beside them.

The names an installed server reads do not move with the code: noust.main:cli (the console script of every copy installed before the command tree moved), python -m noust, and the three modules a transient unit runs with python -m (managers/network_maintenance, managers/server/security_checks and managers/server/security_pending). tests/test_installed_names.py fixes them; a move that touches one leaves a one-line module under the old name and records until when in core/legacy_names.py.

Adding a deployer

  1. Create deployers/mytype.py implementing the deployer interface.
  2. Set APP_TYPE, DISPLAY_NAME, DETECTION_FILES, DEFAULT_PORT.
  3. Implement detect(), get_install_command(), get_build_command(), get_start_command().
  4. Register with DeployerRegistry.register(MyTypeDeployer) at the end of the file.
  5. Add a detect() test with a fake file tree, including the ambiguous cases.

Build on BaseDeployer and the type gets releases, the health gate, domains, limits and deploy hooks for free. MonorepoDeployer and DockerComposeDeployer implement AppDeployer directly, which is why they deploy in place (SUPPORTS_RELEASES is read with getattr, defaulting to false); they share the hooks, the site writer and the domain capability (helpers/hooks.py, helpers/site.py) rather than re-implementing them.


Deploy engine

An application is on one of two layouts, recorded in apps.layout:

  • inplace (1.x): the service runs the tree every update rebuilds.
  • releases: releases/<id>/ per deploy, current pointing at the active one, .env and persistent paths in shared/, a git cache in repo/.

BaseDeployer works with three paths, and code must use the right one:

  • app_path: the application directory the store records.
  • build_path: where the code being deployed is built. The new release on releases, app_path in place. Everything that reads or builds the project uses it.
  • runtime_path: what the unit and the web server are given. app_path/current on releases, app_path in place, so one unit and one site serve every release.

In place the three are the same directory, which is what keeps that layout exactly as it was. Outside a deploy, ask core/appdir/layout.py: env_file_for(app) for the .env, code_path_for(app) for the running code. Never join app_path with ".env".

Rules:

  • An in-place application is never converted implicitly. choose_layout() keeps the layout an existing application has, a redeploy that asks for another is an error, and ReleaseManager.activate() refuses to replace a real current directory. Only migrate.migrate(), called by noust app migrate and POST /api/apps/{d}/migrate, moves an application onto releases.
  • Every activation passes the same HealthGate: deploy, update, rollback, migration and limits applied with a restart. When it fails, what served before is put back (the previous release, the in-place tree, the old limits) and the error carries the probes and the journal verbatim.
  • lifecycle.update_app is the only update. The CLI, the console's job and the webhook all call it.
  • Nothing is written through a symlink found in a release or in shared/. A repository is untrusted input.
  • app_path is not always under the apps directory. An adopted stack lives where it already ran (/opt/shop). Never build apps_directory / app_name: use the store's app_path (lifecycle, BackupManager and deletion already do), and never remove a directory Noust did not create unless the operator names it (--remove-adopted-directory).

Access, audit and builds (3.1)

  • Every API route declares a permission in web/permissions/routes_<area>.py; one global dependency enforces it and publishes it as x-noust-permission, and a route without one is refused (a test fails first). Roles (viewer, operator, admin, security, auditor) are sets of permissions in web/permissions/roles.py; accounts live in core/accounts/. The master access token is break-glass, not the normal way in. Root-equivalent actions can require a second person (core/accounts/approvals.py, guard on the router).
  • One audit trail: noust.core.audit.record(event, actor=...) with a closed catalog (core/audit/catalog.py; a test scans the code for undeclared events), an HMAC chain, and shipping to journald/syslog. CLI commands that change something are audited by cli/app.py.
  • Builds never run as root. Install, build and hooks run through CommandRunner(..., sandbox=SandboxSpec(...)) as noust-build in a transient systemd unit, failing closed when the sandbox cannot be proven; migrations run as the app's user. Existing apps switch per app after a test build (noust app sandbox test|enable).
  • Dry-run probes are declared by exact argv (core/runner.py READ_ONLY_PROBES and each area's probes.py); a command is never read-only because one of its arguments looks harmless.
  • The ENS profile (security.profile: ens-medium) lives in one module, core/ens/profile.py; every area reads its defaults from there.

Compose, hooks and the web server (3.2)

Operator documentation is docs/compose.md.

  • noust.yaml is untrusted input and the schema is closed. project_file.py reads it (hooks.pre_deploy, hooks.post_deploy, backup.databases); an unknown key, .., a link or an absolute path outside a container is a ValidationError naming the field, never ignored. hooks.resolve_hooks is the one answer to "which hooks run": the operator's document (store app_hooks, root-equivalent to write) replaces the repository's whole, never merged. A hook is an argv (shlex, never a shell) through CommandRunner: in Compose a one-off container of the new image (docker compose run --rm --no-deps), elsewhere the release phase of the sandbox. pre_deploy failing aborts before any traffic moves; post_deploy failing is a deployment with warnings and the deploy_hook_failed notification. Prisma's automatic migration aborts on failure and does not run when hooks are declared.
  • Schema changes are a decision. A hook marked migrates (read against the migration tools' "nothing applied" wording) or Prisma applying a migration sets deployments.schema_changed; every way back (rollback, releases rollback, update --commit, files-only restore, the API and the console) goes through lifecycle.require_schema_change_confirmed and refuses without schema_changed_ok. Noust puts code back, never a database. The automatic go-back after a failed gate still happens and says so first.
  • An operator's site is never rewritten or deleted (helpers/site.py; the "Generated by Noust" marker decides). A domain becomes a site file in one place, webserver.config_path through site_name_for, because an adopted app's file may not be named after its domain (shop for shop.example.com).
  • The relay (compose_relay.py) shares its parts with blue/green and is opt-in through the same zero-downtime command. nginx reaches each relayed service through /etc/nginx/noust-upstreams/<app>/<service>.servers (core/paths.py; upstream names are noust_bg_<app>, and a site the operator wrote that still names the prefix of before 4.0 keeps working: upstream_names.py defines both and noust setup doctor says the line to change); an operator's site includes it itself and Noust prints the exact line. An interrupted update leaves a relay that the next update and noust setup doctor resolve.
  • Adopting never changes what runs. compose_adopt.py reads the project from the containers' labels (apps.compose_project, passed as -p everywhere), proves the stack with up --dry-run --no-build, creates and enables the unit without starting it. No Compose update cleans a checkout: it is brought to the branch and what git does not track (the .env, bind-mounted data) stays.
  • A stack's databases are dumped before every Compose update, in the pre-update backup, and a failed dump stops the update (StackBackupError): without it a migration cannot be undone. Passwords are read inside the container, never in argv. backup_before_update and backup.databases: off turn it off.
  • siteconf is the one nginx/Apache parser. render(parse(text)) == text for any text the server accepts; edit operations change only the bytes of the element they name; route applies the server's own location algorithm. The API (structure, config/edit, route, topology) writes nothing: the visual editor produces text and saves it through the one PUT .../config (rule 3 and 4). A site's config test runs inside the live nginx.conf with the enabled sites.
  • One definition of a worker: compose_ports.is_headless_stack (no TCP port published). Deploy, store, diagnose, the monitor and the summaries judge it by its containers.
  • Accounts and the sandbox. app_identity.service_account is the only answer to "as whom"; new apps get noust-app-<name>, existing ones only by noust app identity migrate. The sandbox is the default for apps from before 3.1 through sandbox_trial.trial_before_update: it never breaks an update that worked.
  • Monitor and jobs. app_unreachable/app_recovered come from the monitor's reachability (three failures over a minute, the deploy's own HealthCheck); process samples (top 5 CPU and memory per minute, commands redacted) feed GET /api/timeline; a job records its transient unit (jobs.unit) so job_reconcile finishes it after a console restart.
  • Store v13 is core/schema_v13.py; every new column has its own validated setter and survives a redeploy's full-row write. The harness (tests/integration/run.py) now has Docker inside; an optional scenario deploys a clean clone of a real production Compose application from a private repository, and nothing ever runs against the host's Docker or its containers.

Fleet

A central is a Noust whose job is several other Noust servers (central.role: hub never deploys anything itself, server also runs its own applications). It reaches each one over an SSH tunnel it opens outward and drives it through its own API: rule 3 again, so a central re-implements nothing a node does. web/api/node_proxy.py forwards every call, the event stream and the log and job WebSockets over that tunnel; nothing runs twice.

  • The central never gets a shell. noust fleet authorize runs on the node, as root, and installs the central's key for the unprivileged noust-tunnel account (no home, nologin, keys in a root-owned file), with a Noust Match User block in sshd (AllowStreamLocalForwarding no, AllowTcpForwarding local, PermitOpen 127.0.0.1:<port>, PermitTTY no, ForceCommand /usr/bin/false) and the same limits repeated as key options (restrict,port-forwarding,permitopen=...,permitlisten="127.0.0.1:1",command="/usr/bin/false", built in fleet/authorize.py). Both layers matter: permitlisten narrows TCP ssh -R but not Unix-socket forwarding, which with a key in root's authorized_keys (3.0) could create a socket as root at any path. --ssh-user root exists only behind an explicit confirmation.

  • Each node sets a ceiling for its central. noust fleet authorize --access read|deploy|admin (default admin) and --allow-host-access (SSH keys, sshd, firewall: off by default) are enforced by the node in the fleet admission (fleet/policy.py permits()), whatever the central claims; the central reads them from GET /api/auth/fleet/self.

  • Fleet tokens are accepted only from loopback. They travel through the tunnel, never across the network, and only a request presenting one may carry X-Noust-Actor, so a node's audit log names the operator behind the central rather than just the central's name.

  • Elevation is the node's call, not the central's. A node marks what needs sudo mode with the x-noust-requires-elevation OpenAPI extension; the central asks its own operator to confirm before forwarding such a call and vouches for it with X-Noust-Elevated, and the node refuses the call anyway if the central did not vouch - one source of truth, checked on both sides, so a compromised or outdated central cannot forward its way past a node's sudo mode.

  • A host-key change is never accepted silently. Each node's key is pinned to the central's own known_hosts the first time it is added; a change closes the tunnel and says why instead of trusting whatever key answers next.

  • A guided enrollment keeps no credential. noust fleet enroll and POST /api/fleet/enroll connect once with a password or a key, to the host key the operator confirmed, and do what the manual join does (NodeEnroller calls NodeManager.add). The credential reaches ssh through its environment or a file in a private, memory-backed directory removed on every way out, never an argument, a log line, a job result, an audit event or an approval (ONE_TIME_FIELDS leaves it out of the call's fingerprint). Noust is installed with the central's own copy of install.sh, sent over the connection: the server downloads no script.

  • The console is published, never left open. noust console expose refuses while any account can sign in without a second factor, under the ENS profile unless forced and given an allow-list, and on a name that is an application's or a site Noust did not write; there is no plain-HTTP form. web._install_and_start keeps the trusted proxy it recorded whenever the unit is written again.

See docs/CENTRAL.md for running a central, sealing its secrets and adding a server, and docs/PLATFORMS.md for the platforms and the installer.

Teams (4.0)

Teams, projects and environments (docs/teams.md) are one more instance of rule 4. Applications, sites, databases and backup schedules belong to a project, and what a principal sees of them is decided in the data layer, not in the routes.

  • Read through ScopedStore. A router asks noust.core.teams.scoped_store() for apps, sites, databases, backup schedules, deployments and jobs; the store's own get_app, list_sites and the rest are for what runs as the instance. tests/test_scope_architecture.py fails on a router that reads one directly (with the few places allowed, each with its reason).
  • Every route is classified in web/permissions/scopes.py (resource, scoped, open, instance), like its permission in routes_<area>.py. enforce_scope runs on the API router before the approvals guard; a resource outside the principal's teams is a 404 with the body of one that does not exist, never a 403. A new route under /api/apps/{domain} is covered by its prefix; a new area needs a rule.
  • A new row gets an owner from the store (core/teams/ownership.py): its application's, the one a job was queued for (create_job(..., ownership=...)), else the default. Never insert into apps, sites, databases or backup_schedules around create_*, and never write project_id or environment_id anywhere but a move (TeamStore.move_app_to).
  • A manager that lists applications around what a request names reads through ScopedStore too (the deploy keys do: DeployKeyManager._visible). Its scope is the ambient one, the request's inside a request and the instance's in the CLI and in a job, so one implementation serves both without showing a team another team's applications. A route whose body can ask for something the server owns (install: true installs a package as root) calls web.api.scope.require_instance_scope. A change to something shared by repository (rotating a deploy key that reaches another team's repositories) is refused in the manager, not the route, whoever calls it.
  • A table another area adds for what an application or a database owns says how a team reaches it. A column named app_id, app_domain, domain, db_name, database or repository in a schema_v14.py fragment (or an ALTER of an existing table) must be listed in OWNED_TABLES of tests/test_scope_architecture.py with the routes that reach it and how each is classified: a resource under /api/apps/{domain}, a list the handler filters, or the server's. A table of a server-wide thing (the console's own name) goes in NOT_OWNED with the reason.
  • Team roles never reach the machine (web/permissions/team_roles.py); a permission that is about the server is never added to TENANT_PERMISSIONS.

Conventions

  • App name: the domain with dots as dashes (shop.example.com is shop-example-com). It names the directory /var/www/apps/{app_name}/ and the unit {app_name}.service; units from before 0.14.1 carry a legacy prefix (legacy_names.UNIT_PREFIX) until the application's next deployment renames the unit as part of the restart it makes anyway. Noust's own units are noust-web, noust-monitor, noust-previews, and the cron and backup timers noust-cron-* and noust-backup-*; they carry the "Generated by Noust" marker (the first line of before 3.0 is still recognised until noust setup rename rewrites it).
  • Paths: /etc/noust, /var/lib/noust (store noust.db), /var/backups/noust, /var/log/noust. Ask core/paths.py, never spell one. Noust 4.0 moves nothing: a 2.x server upgrades to 3.3.x first, and core/retired_names.py refuses to start on a server that was not moved there.
  • Environment variables are NOUST_*, read through paths.getenv.
  • The product has no other name. What lives outside Noust's control and was made under the name of before 3.0 (a header a script sends, a token in a CI secret, a file in a customer's repository, a site an operator wrote, a backup or an export) is accepted on input and never written: core/legacy_names.py lists each name with why it is kept and until when (5.0; backups and exports for as long as the file exists), and core/legacy_archive.py reads the old archives. What Noust holds itself (a unit, a database account, a file in an application's tree, an image tag, a backup folder) is renamed the next time that thing is used, never in a step that restarts something outside a deployment, and noust setup rename finishes the rest. The paths of before 3.0 are spelled in core/retired_names.py, and in the package scripts that stop an upgrade over data nobody moved. Nowhere else: tests/test_no_legacy_name.py fails on the old name, in any case, in src, docs, scripts and the packaging, and a name that has to be recognised goes through legacy_names. The changelogs are history and are not rewritten.
  • Feedback items are numbered and commit bodies cite them as "item N".
  • Google-style docstrings on everything public, with Args/Returns/Raises.
  • Type hints everywhere, modern syntax (X | None, list[str]).
  • Actionable errors: raise DeploymentError("what happened", details="how to fix it").
  • A pattern that judges text is applied with fullmatch, never match plus a $: $ matches before a trailing newline, which is how validate_app_name("shop\n") was once accepted. tests/test_regex_anchors.py fails on a new one. Text a person or a repository supplies is judged at one place by an allowlist (validators/), and that place is attacked by tests/test_adversarial_inputs.py: add a class there when you add a validator.
  • Anything that opens a request to a host somebody typed goes through core/outbound.py (require_public_host), never its own resolver. A name that becomes part of a request to a platform's API is made one segment by path_segment, and the client checks the path it sends with api_path.
  • Code scanning alerts are fixed in one place, taught to CodeQL as a model that a test proves, or dismissed in GitHub with the reason written down; none is left open (a job fails while one of high severity is) and none is silenced in a line of code (docs/code-scanning.md). A function that returns a name only when it is acceptable goes in .github/codeql/extensions/noust-python with its proof in tests/test_codeql_models.py, once measured to remove an alert.
  • Comments explain why, never what.
  • Start-up is a budget. noust --version and noust --help import 16 Noust modules between them and tests/test_cli_startup_budget.py fails past 24. A package __init__ that re-exports classes does it through core/lazy_exports.py: importing anything below a package runs its __init__, so imports at the top of it load everything the package offers. The root's --help and the shell completion read each command's line from cli/command_summaries.py; after changing a command's help or adding a command, run python scripts/generate_command_summaries.py (a test fails until you have).
  • No emojis in code, comments or commit messages.
  • Absolute paths in systemd units; shutil.which() or /usr/bin/, never nvm paths.
  • Noust requires root. There is no sudo inside argv and no run_command_sudo.

Commands

Shell
pip install -e ".[all,dev]"     # development install
 
pytest                          # tests
pytest --cov=noust              # with coverage
pytest --shard 2/6              # one slice of the suite, by whole files (CI runs each slice on its own machine)
pytest -m web                    # one area of the suite: the first word of a test file's name (tests/support/areas.py)
ruff check src/noust tests      # lint (blocking in CI)
ruff format src/noust tests     # format (blocking in CI)
mypy                            # types (blocking in CI)
 
python scripts/release.py --check         # version consistency
python scripts/security_model.py --write  # regenerate docs/SECURITY-MODEL.md after changing a role, a header, a route or a guarantee
python scripts/generate_command_summaries.py  # regenerate cli/command_summaries.py after changing a command's help or adding a command

The integration harness deploys for real (nginx, systemd, Docker, a database) inside one privileged container, and is not part of pytest. On a machine whose Docker is shared, name what a run makes so it can be found and removed by that name and by the label noust.test=1 (never a prune):

Shell
export NOUST_IT_PREFIX=noust-test-me NOUST_IT_IMAGE=noust-test-me-image:latest \
       NOUST_IT_DOCKER_VOLUME=noust-test-me-docker NOUST_IT_MEMORY=10g
python tests/integration/run.py                          # every scenario but the optional ones
python tests/integration/run.py --scenario procfile --keep   # one (a substring); leave the container
python tests/integration/run.py --reuse <container> --skip-image-build --scenario procfile
                                                         # again, in the kept container, in seconds
python tests/integration/run.py --upgrade-from-3.3       # 3.3.1 with every legacy name -> this tree
python tests/integration/run.py --install-matrix         # the installer on each release (network)

The console needs Node 22 (the major in panel/.nvmrc); CI runs every one of these:

Shell
cd panel && npm ci              # the lock file, exactly; never `npm install` in CI
npm run lint                    # eslint, zero warnings
npm run typecheck               # tsc, strict
npm test                        # vitest unit + component tests (axe included)
npm run check:api               # schema.gen.ts matches openapi.json (npm run gen:api to fix)
npm run build                   # writes src/noust/web/static: commit the result
npm run check:bundle            # the build's gzip weight against panel/bundle-budget.json
npm run i18n:routes             # rewrite src/i18n/routeNamespaces.gen.ts after a route or a page's texts change
npm run e2e                     # Playwright: every page, both themes, zero axe/CSP violations
npm run e2e:screens             # screenshots of every route, both themes + 390px mobile

To work on the console against the real API, run a seeded, sandboxed backend and the Vite dev server, which proxies /api, /events, /hooks and /ws to it:

Shell
python scripts/console_server.py --port 8080    # prints {"url", "token", ...}; sign in with the token
cd panel && npm run dev                          # http://localhost:5173

console_server.py runs the real FastAPI app under uvicorn over a store seeded by tests/support/panel_factory.seed_console_state, with every system path redirected into a temporary directory and a fake runner answering systemctl, journalctl, nginx and certbot, so nothing on the development machine is touched. --totp turns on the second sign-in factor; the E2E suite starts one per Playwright worker.


Releasing

The version has one source of truth: [project].version in pyproject.toml.

Shell
python scripts/release.py 1.0.0 -m "Summary of the change"
git commit -am "v1.0.0: Summary"
git tag -s v1.0.0 -m "Release v1.0.0"
git push && git push origin v1.0.0

scripts/release.py propagates to setup.py, src/noust/__init__.py, the RPM spec in rpm/, the .dsc in obs/ and both changelogs. CI runs --check and refuses to publish on a mismatch. Do not edit those files by hand.

The README opens with a note about the newest minor release only: a minor replaces it, never stacks another above it. Earlier releases are in their changelogs (docs/CHANGELOG-<version>.md).

The tag is signed with the maintainer's SSH key (git config gpg.format ssh, and the same public key registered on GitHub as a signing key, so the tag shows as verified): SECURITY.md promises it from 4.0.0 on.

A release that fixes a vulnerability is released with a changelog bullet that starts SECURITY: (-m "SECURITY: what it fixes (GHSA-xxxx-xxxx-xxxx)"). release.yml then titles the GitHub release "(security release)", which the update check reads so that every installation behind it, its noust health and its console say so. The same release renews .well-known/security.txt.

GitHub Actions publishes to PyPI and OBS on tag push, and the central's container image to ghcr.io/perkybeet/noust. OBS builds: https://build.opensuse.org/package/show/home:Perkybeet/noust (15-30 min)

A release is announced only when every distribution serves it. The GitHub release is created as a pre-release (servers' update checks and releases/latest/download/install.sh read the latest one), release.yml then waits in obs-published (scripts/obs_published.py, which reads the published index of each repository of managers/server/platforms.py) and only the announce job makes it the latest. Run python scripts/obs_published.py vX.Y.Z by hand to see who is behind. The console's Fleet > Updates shows the target version and how many servers are there, and the self-update (the console's and the fleet's "Update Noust") upgrade noust.

The pictures of the console in the README and the guides (docs/assets/console/) are recorded in SCREENS.json with the release they show and a hash each, and release.yml refuses a tag whose pictures show an earlier minor release: before tagging a minor, run cd panel && npm run docs:screens (it retakes them from a seeded server and records them) and commit the folder. CI only notes the lag between releases, and fails on a picture changed by hand or one nobody recorded (python scripts/docs_screens.py check).

Distribution names: noust on PyPI and OBS. noust declares Conflicts with the packages of before 3.0 that it replaced, never Obsoletes: a server on the 2.x line goes through 3.3 first, and the %pre and preinst scripts stop an upgrade over data nobody moved. The container is in packaging/container/.


Packaging notes

The OBS tarball is produced with git archive HEAD, so only committed files ship and the build environment has no network. That is why the console's Vite build is committed to src/noust/web/static/: Node never runs during packaging, and the web/static/**/* glob in pyproject.toml ships it in the wheel, sdist, deb and rpm. Frontend dependencies are build-time only; nothing but the build ships, and it loads nothing from a CDN. The CI panel job rebuilds and fails when the committed build differs from what panel/ produces, so after any change under panel/src run npm run build and commit src/noust/web/static.

When adding a dependency, declare it in all four places: pyproject.toml, setup.py, obs/debian.control and the RPM spec in rpm/. tests/test_architecture.py fails if an import is undeclared. Check the package exists on every target: python3-inquirer does not exist in Debian or Ubuntu, which is why interactive mode never worked there.

Import Debian RPM
click python3-click python3-click
jinja2 python3-jinja2 python3-jinja2 (openSUSE: python3-Jinja2)
yaml python3-yaml python3-pyyaml (openSUSE: python3-PyYAML)
rich python3-rich python3-rich
questionary python3-questionary (Recommends: absent on Debian 12, Ubuntu 22.04) python3-questionary
fastapi python3-fastapi python3-fastapi
starlette python3-starlette python3-starlette
pydantic python3-pydantic python3-pydantic
uvicorn python3-uvicorn python3-uvicorn
psutil python3-psutil python3-psutil

Every model the API adds goes through web/pydantic_compat.py: Ubuntu 24.04 and Debian 12 ship pydantic 1.10, and a CI job pins it.

The packaging scripts and the specs say nothing about the product's names from before 3.0 except where an upgrade over data nobody moved has to be stopped (Debian's preinst, the RPM %pre) and in the Conflicts lines; tests/test_no_legacy_name.py holds them to that. A real customer's name is as unwelcome in a fixture as the old name is in a script: tests/test_no_real_customers.py fails on it, so examples use example.com, example.net, example.org or an invented name.


The panel

The design system is docs/DESIGN.md, and it is normative: tokens, the seven page templates (components/page), components, patterns and copy rules. A page is built from a template and kit components; when something is missing the kit (and its gallery at /__design) grows first. ESLint rules and a ratchet (styles/design-rules.test.ts, baseline that only goes down) enforce it; read it before touching panel/src.

The Noust console is a React single-page application. Its source lives in panel/; its build is committed to src/noust/web/static/ and served by server.py: hashed chunks under /assets (cached immutable for a year) and index.html (no-store) for every GET outside /api, /assets, /events, /ws, /hooks and /health, where a miss answers JSON. The backend renders no pages: the console is a client of the JSON API, the /events stream (machine, metrics, app, job, state, notice) and the log and job WebSockets, like any script. Destructive endpoints depend on require_elevated (sudo mode, api/deps.py); the console answers the 403 elevation_required with its "Confirm it's you" dialog and retries once. FastAPI's OpenAPI schema is exported to panel/openapi.json and compiled into panel/src/api/schema.gen.ts, so the console cannot call an endpoint that does not exist.

The Content Security Policy is strict: script-src 'self'; style-src 'self', no unsafe-inline anywhere, and require-trusted-types-for 'script'. That rules out inline <script>/<style>, style attributes in markup, data: fonts (hence assetsInlineLimit: 0 in vite.config.ts) and every string-to-DOM sink (innerHTML, insertAdjacentHTML, eval). React sets styles through the CSSOM, which the policy allows, and Base UI runs with disableStyleElements. Never add a library that injects <style> elements or writes HTML strings (Radix, sonner and xterm all do, which is why they are not used). A policy is only enforced in a browser, so the E2E suite collects every securitypolicyviolation and console error and fails on any, and runs axe on every page in both themes (WCAG 2.2 AA, zero violations).

Colour only ever encodes state (running green, in progress amber, failed red, stopped grey), plus the violet accent for interactive elements; every state also has a shape and a text label. Navigation, surfaces and text are achromatic, so anything coloured on screen is telling the operator something. UI copy is English and Spanish, sentence case, and lives only in the typed catalogs of panel/src/i18n (rules and glossary in its README): whole sentences with placeholders, never fragments. A language beyond these two is not a typed catalog but a JSON file in panel/src/i18n/community/ that anyone can contribute without TypeScript (npm run i18n:new, guide in docs/TRANSLATING.md); tests hold it to English.

The catalogs are not in the script every page loads. Each namespace of each typed language is a chunk of its own; only the chrome (what the frame of any page draws) is bundled, in English (CHROME in panel/src/i18n/catalogs.ts). The router loads a route's namespaces before the page is shown, from a map generated from the source: after a page starts to draw another feature's texts, or a route is added, run npm run i18n:routes and commit routeNamespaces.gen.ts (a test fails until you have). The rest of the language is fetched while the browser is idle, and t() outside a render never waits or throws (panel/src/i18n/README.md, "How the texts reach the browser"). Nothing but catalogs.ts imports a catalog; a test fails on the next import * as en from "./en".

A system error is never paraphrased. Show nginx's or systemd's own output verbatim in mono, with the suggested fix above it.

A console test waits for what it asserts, never for a time. CI failed by turns on tests that passed on a fast machine: a read a field's blur had scheduled 80ms ahead started again after a Cancel, and two announcements fifty milliseconds apart in one live region left only the second drawn. A longer timeout on a findBy or a waitFor hides that race on the next machine, it does not remove it. Take the thing the test waits for out of the clock (the announcement is recorded with vi.mock, a request is counted), prove an absence by counting after the window in which it would have happened, and run the test under a loaded CPU before trusting it (taskset -c 0-3 with a few busy loops on the same cores, and repeats: 40 on the test).

Something on this page is wrong or unclear? Report it on GitHub.

Help, contributing and the project’s policies are under Project.

Search the documentation

Type to search every page.

↑ ↓ to move, Enter to open, Esc to close