---
title: 'Deploying a project'
group: 'integrations'
collapse_after: 'What deploy actually does'
---

## 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](#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](https://nuzur.com/cli))
- 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 as `root` or 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](#where-to-deploy))
- For a full app deploy: a **Go Code Gen configuration** for the project. Run `nuzur-cli go-code-gen` once (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:

```bash
nuzur-cli deploy --host 203.0.113.10 --project my-project
```

Or have nuzur create the server too — no `--host`, just a region:

```bash
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:

```bash
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.

1. **Resolves your project and version** (defaults to the latest) and logs you in if needed.
2. **Generates the application code** with the Go Code Gen extension (including a Dockerfile) into a persistent workspace you own — `./nuzur-<identifier>` by default.
3. **Mints a single-use provisioning token** (valid 15 minutes) so the server can pair itself without an interactive login.
4. **Creates the server** and waits for SSH — managed providers only; with `--provider ssh` it uses the host you gave it.
5. **Copies the generated source** to the server over SSH.
6. **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.
7. **Pairs the nuzur agent** on the box and registers the database as a named connection.
8. **Applies your schema** to the empty database through the SQL Push (local) extension.
9. **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`](https://docs.digitalocean.com/reference/doctl/), `--region` |
| `hetzner` | Creates a Hetzner Cloud server for you, adds a firewall, and deletes it on destroy. | [`hcloud`](https://github.com/hetznercloud/cli), `--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:

```bash
# 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:

```bash
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 `cpx22` isn't available where you're deploying, run `hcloud server-type list` and 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:

```bash
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

```bash
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

```bash
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

```bash
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 `CREATEDB` so 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](#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

```bash
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

```bash
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; `ufw` opens only SSH and the front-door ports.
- The application runs as a least-privilege database user.

---

## Next steps

- [Running a local agent](/docs/local-agent)
- [Go Code Gen](/docs/extensions-go-code-gen)
- [SQL Push (local)](/docs/extensions-push-local)
- [Data Manager overview](/docs/data-manager-overview)
