Deploying a project
What is deploy?
nuzur-cli deploy turns an approved model into a running server with a single command. Either point it at a Linux box you already own or let it create the server for you on DigitalOcean or Hetzner. From there it generates the application code from your project version, builds it on the box, provisions a database, applies your schema, and wires the whole thing back to nuzur — so you can manage the deployed data from the Data Manager straight away.
The generated code lands in a directory you own and can edit — commit it to git, add custom endpoints, and re-deploy to ship them. See Your app source.
You keep the server. nuzur never receives your SSH key, your database password, or your cloud provider credentials — everything runs from your machine over your own SSH connection, and the database password is generated on the box so the plaintext never crosses the wire.
Requirements
- nuzur CLI installed on your machine (download)
- A nuzur account with access to the project you want to deploy
- A server, which is either:
- one you already have — running Ubuntu or Debian (the bootstrap uses
apt), reachable over SSH asrootor a user with passwordless sudo; or - one nuzur creates for you on DigitalOcean or Hetzner, which needs that provider's CLI installed and logged in (see Where to deploy)
- one you already have — running Ubuntu or Debian (the bootstrap uses
- For a full app deploy: a Go Code Gen configuration for the project. Run
nuzur-cli go-code-genonce (or pass--config-file) so the CLI knows which API surface, auth, and options to generate.
Quick start
Deploy to a server you already have:
nuzur-cli deploy --host 203.0.113.10 --project my-project
Or have nuzur create the server too — no --host, just a region:
nuzur-cli deploy --provider digitalocean --region nyc3 --project my-project
Either way that's a full deploy: the latest version of my-project, a self-hosted MySQL database, and the generated API served over HTTP.
For a real HTTPS certificate, point a domain at the server first and pass it:
nuzur-cli deploy --host 203.0.113.10 --project my-project --domain api.example.com
When it finishes, the CLI prints the deployment id, the agent uuid, the public URL, and a Data Manager link that opens the deployed database directly.
What deploy actually does
Each step is idempotent — re-running the same command updates the deployment in place rather than creating a second one.
- Resolves your project and version (defaults to the latest) and logs you in if needed.
- Generates the application code with the Go Code Gen extension (including a Dockerfile) into a persistent workspace you own —
./nuzur-<identifier>by default. - Mints a single-use provisioning token (valid 15 minutes) so the server can pair itself without an interactive login.
- Creates the server and waits for SSH — managed providers only; with
--provider sshit uses the host you gave it. - Copies the generated source to the server over SSH.
- Bootstraps the box: installs Docker, the database engine, and Caddy; creates the database and a least-privilege application user; builds the image; writes systemd units.
- Pairs the nuzur agent on the box and registers the database as a named connection.
- Applies your schema to the empty database through the SQL Push (local) extension.
- Records the deployment locally and reports it to nuzur, where it appears on the Deployments screen with live health.
What ends up on the server
| Thing | Where |
|---|---|
| App service | {identifier}-api.service (systemd, Restart=always) |
| Container / image | {identifier}-api / nuzur/{identifier}:latest |
| App config | /etc/nuzur/{identifier}/config/prod.yaml (mode 0600) |
| DB password | /etc/nuzur/{identifier}/db_password — generated on the box, never sent to nuzur |
| Front door | Caddy, one site snippet per project in /etc/caddy/conf.d/{identifier}.caddy |
| Agent | nuzur-agent.service — one shared agent per box, serving every project on it |
| Backups | Nightly dump to /var/backups/nuzur, 14-day retention (self-hosted databases only) |
| Firewall | ufw allows SSH (22), plus 80/443 and the project's public port for a full deploy |
The database binds to 127.0.0.1 and the agent connects outbound only, so neither is exposed to the internet.
Where to deploy
--provider picks how the server is obtained. Everything after that — the bootstrap, the database, the agent, the schema — is identical across providers.
| Provider | What it does | Needs |
|---|---|---|
ssh (default) |
Uses a Linux box you already have. Also the universal fallback for any provider without an adapter. | --host |
digitalocean |
Creates a droplet for you, adds a cloud firewall, and deletes it on destroy. | doctl, --region |
hetzner |
Creates a Hetzner Cloud server for you, adds a firewall, and deletes it on destroy. | hcloud, --region |
AWS, GCP, Azure, Vultr, Linode, and Scaleway are recognized but not built yet — naming one tells you it's planned rather than failing as unknown. Until then, create the VM yourself and deploy with --provider ssh --host <ip>.
Managed providers
nuzur never handles your cloud credentials. The adapters shell out to the provider's own CLI, so authentication stays wherever you already set it up:
# DigitalOcean — install doctl, then:
doctl auth init
# Hetzner — install hcloud, then:
hcloud context create
Deploy without a --host and the CLI creates the VM, waits up to 3 minutes for SSH, then runs the same bootstrap as any other server:
nuzur-cli deploy --provider hetzner --region nbg1 --project my-project --domain api.example.com
--region is required. The rest have sensible defaults you can override:
| DigitalOcean | Hetzner | |
|---|---|---|
--region |
a region, e.g. nyc3, fra1 |
a location, e.g. nbg1, fsn1, hel1, ash |
--size |
s-1vcpu-1gb |
cpx22 |
--image |
ubuntu-22-04-x64 |
ubuntu-22.04 |
The VM is named nuzur-{identifier} and always logs in as root.
Hetzner: no single server type is offered in every location (the US locations use a different line), so if
cpx22isn't available where you're deploying, runhcloud server-type listand pass a supported one with--size.
SSH keys
Pass --ssh-key-name to attach a key already registered with the provider. Otherwise the CLI uploads the public half of your --ssh-key — or your default ~/.ssh/id_ed25519 or ~/.ssh/id_rsa — under the name nuzur-deploy and reuses it on later deploys.
Provider firewall
Managed deploys also get a provider-level firewall (nuzur-fw-{instance-id}) mirroring the box's own ufw: SSH, plus 80/443 and the public-port range for a full app, or SSH alone for --db-only. It's defense in depth and best-effort — the box's ufw stays the authoritative gate, so a firewall hiccup warns rather than failing the deploy.
Your app source
Deploy generates the app into a persistent directory on your machine and builds from it. That directory is yours: read it, edit it, commit it.
./nuzur-my-project/ # default; override with --source-dir
The path is recorded with the deployment, so later deploys reuse it without you re-passing the flag. --db-only deploys generate no app, so they have no workspace.
Re-deploys keep your edits
Re-running deploy regenerates into the same directory — it does not wipe it. Generated files (the ones carrying a Code generated ... DO NOT EDIT header) are refreshed to match your latest model; any file you've edited that isn't marked generated is left exactly as you wrote it. So your model changes and your hand-written code both survive a re-deploy.
That's what makes the workspace safe to keep long-term: nuzur-cli deploy is both "ship my schema change" and "ship my code change".
Use git
It's a normal directory — put it under version control:
cd nuzur-my-project
git init
git add . && git commit -m "initial generated app"
Now every re-deploy shows up as a reviewable diff: you can see exactly what codegen refreshed and what you changed. On first creation deploy writes a .gitignore and a .dockerignore for you, keeping secrets (config/prod.yaml, .env, *.key, *.pem) and build artifacts out of git and out of the image. Neither file is overwritten if it already exists — once they're in your workspace, they're yours.
The production config on the box (
config/prod.yaml, holding the database password) is generated on the server and gitignored here. Committing this workspace does not commit your secrets.
Custom endpoints
Deploy with --custom and the generator emits a user-owned custom layer alongside the generated CRUD, which regeneration never overwrites:
| File | What it's for |
|---|---|
app/rest.go |
Custom REST routes — no codegen needed |
app/grpc.go |
Override or extend generated gRPC endpoints |
app/idl/proto/custom.proto |
Brand-new gRPC RPCs — fill it in, then run app/idl/proto/gen.sh |
Edit, re-run the same deploy command, and your endpoints ship with the rest of the app.
Deployment modes
Full app (default)
Generates the API, builds it on the box, self-hosts the database, and puts Caddy in front of it.
Database only
nuzur-cli deploy --host 203.0.113.10 --project my-project --db-only
Installs the database engine, pairs the agent, registers the connection, and applies the schema — but generates no app, no Docker, and no Caddy. You manage the database entirely through nuzur (Data Manager, SQL Push, queries). Because no code is generated, this works for any project without a Go Code Gen configuration, and only SSH is opened on the firewall.
Use an existing database
nuzur-cli deploy --host 203.0.113.10 --project my-project \
--db-dsn "postgres://user:pass@db.example.com:5432/mydb?sslmode=require"
The app and agent connect to a database you already have (local or remote, MySQL or Postgres) instead of self-hosting one. The engine is inferred from the DSN — a postgres:// or postgresql:// URL means Postgres, anything else is parsed as a MySQL DSN (user:pass@tcp(host:port)/db?params). The DSN must include a database name.
With --db-dsn, deploy skips the database install, the user creation, and the backup cron — your provider owns those. The database is never dropped on destroy, even with --purge.
Deploy from a stored team connection
nuzur-cli deploy --host 203.0.113.10 --project my-project --connection <connection-uuid>
If the target database is already configured as a team connection in nuzur, deploy against it by uuid instead of retyping a DSN. The CLI resolves the credentials itself — the password is decrypted from KMS server-side, so it never lands in your shell history. --connection and --db-dsn are mutually exclusive, and the connection must belong to the project's team. Everything downstream behaves exactly like --db-dsn.
Save a database as a team connection
An external --db-dsn database registers only a connection scoped to you, so teammates can't open it in the Data Manager. After a successful --db-dsn deploy on an interactive terminal, the CLI offers to save it as a team connection. Decide up front in scripted runs with --save-connection or --no-save-connection — the default is not to save.
Saving requires you to be a team admin, and it never fails the deploy: if it can't be saved, deploy warns and moves on. Note that the team path connects to the database directly from nuzur cloud, so its host and port must be reachable from there — your own agent path works either way. This isn't offered for self-hosted deploys (a 127.0.0.1 database is unreachable from the cloud) or for --connection (already a team connection).
Choosing an engine
--db mysql (default) or --db postgres selects the self-hosted engine. For Postgres, --db-schema targets a namespace (default public); in MySQL the database is the schema, so the flag is ignored.
The self-hosted Postgres role is granted
CREATEDBso nuzur's schema diff can materialize throwaway temp databases when pushing changes. It is still a non-superuser, localhost-only role.
HTTPS and domains
Pass --domain api.example.com (with DNS already pointing at the server) and Caddy provisions a real Let's Encrypt certificate automatically, serving the site on 443.
Without a domain, the project gets its own auto-assigned public port on the host IP over plain HTTP — starting at 8443 and counting up, so several projects can coexist on one box without domains. This is fine for testing; use a domain for anything real.
Flags
nuzur-cli deploy
| Flag | Default | Description |
|---|---|---|
--provider |
ssh |
Where to deploy: ssh, digitalocean, or hetzner |
--host |
Target server IP or hostname (required for ssh) |
|
--region |
Region/location to create the VM in (required for managed providers) | |
--size |
per provider | Instance size/type |
--image |
Ubuntu 22.04 | OS image |
--ssh-key-name |
uploads your key | Name/id of an SSH key already registered with the provider |
--project, -p |
Project name or UUID | |
--version |
latest | Project version identifier or UUID |
--user |
root |
SSH user |
--ssh-key |
ssh-agent / ~/.ssh/config |
Path to an SSH private key |
--port |
22 |
SSH port |
--sudo |
off | Run the bootstrap via sudo (auto-enabled for non-root users) |
--domain |
Domain for automatic HTTPS via Caddy | |
--identifier |
from the generator config, or the project name | Names the DB, service, and config on the box |
--db |
mysql |
Self-hosted engine: mysql or postgres |
--db-only |
off | Database + agent only — no app, Docker, or Caddy |
--db-dsn |
Use an existing database instead of self-hosting one | |
--connection |
Deploy against a database saved as a team connection (mutually exclusive with --db-dsn) |
|
--save-connection / --no-save-connection |
prompt on a TTY, otherwise no | Save an external --db-dsn database as a team connection |
--db-schema |
public |
Postgres schema to target (ignored for MySQL) |
--api |
project's last config | API surface: rest, grpc, or both |
--auth |
project's last config | Auth middleware: disabled, jwt, or keycloak |
--custom |
off | Generate the custom application layer for custom endpoints |
--source-dir |
./nuzur-<identifier> |
Directory for the app's source — where deploy generates, and you edit |
--config-file |
last-used config | Path to a JSON Go Code Gen config |
--cli-install-cmd |
GitHub releases | Override how the nuzur CLI is installed on the box |
--schema-push-extension |
sql-push-local |
Extension used to auto-apply the schema |
--web-url |
nuzur cloud | nuzur web base URL for the Data Manager link |
Pick --api by the consumer: REST for JS, web, and browser clients; gRPC for Go and backend clients.
nuzur-cli destroy <deployment-id>
| Flag | Description |
|---|---|
--purge |
Also drop the database and app user (irreversible; the default keeps your data) |
--keep-vm |
For a managed-provider deployment, keep the VM instead of deleting it |
--skip-server |
Only revoke the agent and remove local state; leave the server untouched |
--user / --port / --ssh-key |
Override the deployment's recorded SSH settings |
--sudo |
Run the teardown with sudo (auto-enabled for non-root users) |
After deploying
Manage your data
The CLI prints a Data Manager deep link that opens the deployed database with the connection preselected. The deployment also appears under Deployments in the nuzur web app, showing its configuration (host, engine, mode, ports, URLs, project version, CLI version) and live status pulled on demand from the agent: whether the app service is active, the container status and restart count, and whether the database is reachable. If the agent is offline, the configuration still shows and the health reads as unknown.
Ship changes
Re-run the same deploy command to roll out a new version — of your model, your code, or both. It regenerates your app source in place (refreshing generated files, keeping your edits), reuses the box's agent and connection, rebuilds the image, restarts the service, and applies the schema diff. Passwords, JWT signing keys, and ports are persisted per project, so a re-deploy never rotates them out from under a running app.
You can also push schema changes from nuzur at any time with SQL Push (local) or a change request — the agent applies them to the deployed database.
List deployments
nuzur-cli deploy list
Deployment records live on your machine at ~/.config/nuzur/deployments/<id>.json, and they're what destroy reads. Deploy from a different laptop and that machine won't know about the earlier deployment — the nuzur Deployments screen is the shared view.
Multiple projects on one box
One server can host several projects. Each gets its own database, config directory, systemd service, container, Caddy site, and public port — all namespaced by identifier — while the box keeps a single shared agent.
Deploying a second project reuses that agent instead of pairing a new one. destroy removes only that project's artifacts; the shared agent survives until you tear down the last project on the box.
Deploying a different project under an identifier already used on that host is refused, since they would collide on the derived database name. Give it a distinct --identifier.
Tearing down
nuzur-cli destroy my-project-a1b2c3d4
This removes the project's service, container, image, config, Caddy snippet, and backup cron; drops its connection from the agent; marks the deployment destroyed in nuzur (kept as history); and deletes the local record. Your database is kept unless you pass --purge, and an external --db-dsn database is never dropped.
For a managed-provider deployment, destroy also deletes the VM it created — but only once it's the last project on that box, so tearing down one of several projects never pulls the server out from under the others. Pass --keep-vm to keep it. A BYO-SSH box is never touched; it's yours.
If the box is unreachable, the teardown warns and still cleans up the nuzur side — re-run it later, or use --skip-server. If the VM deletion itself fails, the CLI tells you the instance id so you can remove it and stop the billing.
Security notes
- Your SSH key and cloud provider credentials never leave your machine — deploy runs client-side, and managed providers shell out to your own already-authenticated CLI rather than asking nuzur to hold a token.
- The database password is generated on the server and stored at
0600; nuzur only ever sees connection metadata, never the password. - The provisioning token used for headless pairing is single-use and expires in 15 minutes, and is scoped to your user and project.
- The database binds to
127.0.0.1; the agent is outbound-only;ufwopens only SSH and the front-door ports. - The application runs as a least-privilege database user.