Harbor
Provision Ubuntu servers over SSH and ship zero-downtime, health-checked releases, with rollbacks, encrypted secrets, several servers, container images and push-to-deploy.
On this page
Introduction
Harbor takes your app from your repository to your servers. navree harbor:provision turns a fresh Ubuntu server into one ready to run a Navree app: a firewall, Caddy with automatic TLS, PostgreSQL, Redis and systemd units for the web server, the queue workers and the scheduler. navree deploy builds a release, uploads it, migrates the database, starts it next to the running one, and switches traffic only once its health check passes. navree deploy:rollback goes back to the previous release the same way.
Everything lives in one file, harbor.toml, in your repository: the servers and their roles, the processes, the environment, and the secrets, encrypted. Harbor is part of the navree CLI and works over SSH. Nothing runs on your servers but your app and the services it uses: no agent, no control panel, no account.
Deployment explains what a Navree deployment involves: the binary, the settings, the processes, the proxy and the health checks. Harbor does all of it for you, following the same rules, and this page says exactly what it sets up so you always know what is on your servers.
What you need
- The CLI:
cargo install --git https://github.com/NavreeHQ/navree navree-cli. - One or more servers running Ubuntu 22.04 or 24.04 LTS, on x86-64 or ARM64, from any provider, with SSH access as
rootusing a key. A server with 1 GB of memory runs a small app with its database; 2 GB or more is comfortable. - A domain whose DNS
A(andAAAA) record points at the server that takes web traffic, so Caddy can get a certificate. - An OpenSSH client (
ssh) on the machine you deploy from. macOS, Linux and Windows 10 or later include one. Harbor runs it for every connection, so your~/.sshkeys and agent work as usual. - To build on macOS or Windows, Docker or Podman: Harbor builds the Linux binary in a container. On Linux it can build natively.
Getting started
Create harbor.toml
From your app's directory:
Shell navree harbor:initText created harbor.toml created harbor.key # the secrets key: never commit it updated .gitignore # ignores harbor.keyDescribe your server
Edit
harbor.toml: the package to deploy, the domain, and the server's address. One server can hold every role:harbor.toml app = "crm-api" domain = "crm.example.com" [build] features = ["postgres"] [servers.app-1] host = "203.0.113.10" roles = ["web", "worker", "scheduler", "postgres", "redis"] [env] APP_APP__URL = "https://crm.example.com" APP_MAIL__FROM = "Navree CRM <[email protected]>"Add your secrets
Generate the application key and store the SMTP URL. Both are encrypted into
harbor.toml:Shell navree harbor:secrets set APP_KEY --generate navree harbor:secrets set APP_MAIL__TRANSPORTThe second command asks for the value without echoing it.
Provision the server
Shell navree harbor:provisionHarbor connects as
root, installs and configures everything the roles need, and records the server's SSH host key inharbor.toml. It takes a few minutes the first time.Deploy
Shell navree deployYour app is live at
https://crm.example.com. Commitharbor.toml; from now on,navree deployis the whole release process.
harbor.toml
harbor.toml is the single description of where and how your app runs. This one runs the CRM on one server:
# The package to build and run, and the domain it answers on.
app = "crm-api"
domain = "crm.example.com"
[build]
features = ["postgres"] # Cargo features of the release build
[deploy]
keep_releases = 5 # releases kept on each server for rollbacks
health_check = "/health/ready"
health_timeout_secs = 60
shared = ["storage"] # directories that survive releases
[processes]
web = { command = "serve" }
worker = { command = "queue:work", instances = 2 }
scheduler = { command = "schedule:work" }
[servers.app-1]
host = "203.0.113.10"
roles = ["web", "worker", "scheduler", "postgres", "redis"]
host_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJz…" # written by harbor:provision
[env]
APP_APP__URL = "https://crm.example.com"
APP_MAIL__FROM = "Navree CRM <[email protected]>"
APP_LOG__LEVEL = "info"
[secrets]
APP_KEY = "harbor:v1:wq3xT9…"
APP_MAIL__TRANSPORT = "harbor:v1:Pz0k1c…"
APP_DATABASE__URL = "harbor:v1:b2Nr7Q…" # written by harbor:provisionRoles
A server's roles say what it runs. Give one server every role, or spread them over several:
| Role | The server runs |
|---|---|
web | Caddy, and your app's web process behind it |
worker | The worker process: queue:work, or fleet with Fleet |
scheduler | The scheduler process: schedule:work |
postgres | PostgreSQL, with your app's database and user |
redis | Redis |
Processes
processes says what each role runs. The defaults are the ones above; change a command or run more instances of it:
[processes]
web = { command = "serve" }
worker = { command = "fleet" } # Fleet supervises its own workers
scheduler = { command = "schedule:work" }instances runs that many copies of a worker on each server with the role. The scheduler always runs once per server, and running it on several servers is safe: each task runs once per tick however many schedulers share the database.
The environment
Your app reads its settings from APP_* environment variables, as configuration describes. On a server, Harbor writes them to /etc/<app>/env, readable only by root and the app's user, from three sources, later over earlier:
- the settings Harbor knows:
APP_HTTP__TRUSTED_PROXIES(Caddy on loopback, plusdeploy.trusted_proxies),APP_DATABASE__MIGRATE_ON_BOOT=false(a deploy migrates once, itself),APP_REALTIME__RELAY_URL(Caddy's internal entry, so workers reach the live web instance) andAPP_LIGHTHOUSE__RELEASE(the release id, for Lighthouse); [env], for settings that aren't secret;[secrets], decrypted on your machine during the deploy.
Each web slot also has its own small file, /etc/<app>/blue.env or green.env, with APP_HTTP__ADDR (the slot's port on loopback) and the release that slot runs. Releases also carry your config/app.toml, so settings that are the same everywhere can stay there. As on any server, an environment variable overrides the TOML file.
On a server without the web role, APP_REALTIME__RELAY_URL points at the first web server's internal entry over the private network (http://<private_ip>:8079), so give your servers a private_ip when workers run apart from the web servers and broadcast.
Provisioning
navree harbor:provision prepares every server in harbor.toml; navree harbor:provision app-1 prepares one. It connects as ssh.provision_user (root) with your key and configures the server for its roles. Running it again is safe: it brings the server in line with harbor.toml and changes only what differs, so you also use it after adding a role, a server or a worker.
Provisioning app-1 (203.0.113.10, Ubuntu 24.04, x86_64)
base updates, unattended upgrades, chrony, 2 GB swap, UTC
users navree, with 2 SSH keys
ssh no passwords, root by key only, host key recorded
firewall ufw: 22/tcp, 80/tcp, 443/tcp, 443/udp; fail2ban
postgres PostgreSQL 17, database crm_api; URL saved to [secrets] as APP_DATABASE__URL
redis Redis on 127.0.0.1, 256 MB; URL saved to [secrets] as REDIS_URL
caddy Caddy 2, site crm.example.com
systemd crm-api-web@blue, @green, crm-api-worker@1, @2, crm-api-scheduler, crm-api-backup.timer
Provisioned app-1 in 3m 12s. Run `navree deploy` next.Each area is one bash script, rendered from a template and sent over SSH to bash -s on standard input, so passwords and keys never appear on a command line. Harbor first checks the server: anything other than Ubuntu 22.04 or later, on x86-64 or ARM64, is refused before anything changes. A step that fails stops the run with its server, its area and the script's last output lines; fix the cause and run the command again. Provisioning as a user other than root runs each script with sudo -n, so that user needs password-less sudo.
What a server gets
| Area | What Harbor sets up |
|---|---|
| System | Package updates, unattended-upgrades for security updates, chrony for time, a swap file on servers with less than 4 GB of memory, the time zone set to UTC, journald capped at 1 GB. |
| Users | A navree user that owns the app and runs its processes, with the keys in ssh.authorized_keys, and in the systemd-journal group so it can read the app's logs. Its sudo rights, in /etc/sudoers.d/harbor-<app>, are limited to starting, stopping and restarting the app's systemd units (and enabling or disabling the web slots), starting the database backup and reloading Caddy. |
| SSH | Password logins off; root may log in by key only. The host key is recorded in harbor.toml as host_key, and every later connection checks it. |
| Firewall | ufw, denying all incoming traffic except SSH, and on web servers ports 80 and 443 (TCP, and UDP for HTTP/3). On postgres and redis servers, ports 5432 and 6379 open to your other servers only. On a web server with a private_ip, port 8079 (the internal entry) open to the private addresses of your servers that run workers or the scheduler without the web role. fail2ban watches SSH. |
Caddy (web) | Caddy 2 from Caddy's apt repository: the reverse proxy in front of your app, with a certificate from Let's Encrypt for domain, renewed automatically; HTTP redirected to HTTPS; HTTP/2 and HTTP/3; compression; WebSocket upgrades for broadcasting. aliases redirect to domain. /metrics answers only the addresses in web.metrics_allow, and 404 to everyone else. With [web.custom_domains], workspaces' verified domains too, each with its own certificate (custom domains). |
PostgreSQL (postgres) | PostgreSQL 17 (postgres.version) from the PostgreSQL project's apt repository, a database and a user for the app with a generated password, scram-sha-256 authentication, connections from your servers only, and a daily pg_dump (a systemd timer) kept for postgres.backup_days (7) in /var/backups/postgresql/<app>. |
Redis (redis) | Redis from Ubuntu's own packages (7.0 on 24.04 LTS, 6.0 on 22.04 LTS), a generated password, maxmemory (redis.maxmemory_mb, 256 MB) with allkeys-lru, listening on loopback, and on the private network when other servers use it. |
Node.js (web, with a front end) | Node.js frontend.node_version (24) from NodeSource, and pnpm or Yarn through Corepack when the front end's lockfile asks for one. |
| systemd | Units for the app's processes, below. |
The database URL (APP_DATABASE__URL) and the Redis URL (REDIS_URL), with their generated passwords, go straight into [secrets]: you never see or copy them.
The systemd units
Harbor runs your app's processes under systemd, with the same graceful shutdown the deployment page describes. The web server runs in two slots, blue and green, so a new release can start while the old one still serves:
[Unit]
Description=crm-api web (%i)
After=network-online.target postgresql.service redis-server.service
Wants=network-online.target
[Service]
Type=simple
User=navree
Group=navree
WorkingDirectory=/srv/crm-api/%i
EnvironmentFile=/etc/crm-api/env
EnvironmentFile=/etc/crm-api/%i.env
ExecStart=/srv/crm-api/%i/crm-api serve
Restart=on-failure
RestartSec=5
TimeoutStopSec=100
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/crm-api/shared
[Install]
WantedBy=multi-user.target/etc/crm-api/blue.env sets APP_HTTP__ADDR=127.0.0.1:8081, and green.env port 8082. The worker unit, [email protected] (one instance per processes.worker.instances: @1, @2, ...), and the scheduler unit, crm-api-scheduler.service, are the same with WorkingDirectory=/srv/crm-api/current and their own commands; they start once the first release is live. Every unit has the same hardening: the app can write to shared/ and nowhere else. A postgres server also gets crm-api-backup.service and its daily crm-api-backup.timer.
The Caddy site
/etc/caddy/Caddyfile imports one site file per app from /etc/caddy/sites/, so several apps can share a server:
# crm-api: written by navree harbor:provision; navree deploy points the
# reverse_proxy lines at the live slot (blue).
crm.example.com {
encode zstd gzip
@metrics path /metrics
respond @metrics 404
reverse_proxy 127.0.0.1:8081
}
# Internal entry for workers and the scheduler, always pointing at the live slot.
http://:8079 {
bind 127.0.0.1
reverse_proxy 127.0.0.1:8081
}Caddy sends X-Forwarded-For and X-Forwarded-Proto, and Harbor sets APP_HTTP__TRUSTED_PROXIES=127.0.0.1,::1, so your app sees each client's real address, as behind a reverse proxy explains. A deploy rewrites the site file for the new slot and reloads Caddy gracefully; the deploy user owns this one file, and nothing else in /etc/caddy. On a server with a private_ip and workers elsewhere, the internal entry also binds the private address. When your app's Cargo.toml turns on navree's grpc feature (or depends on navree-grpc), Harbor proxies with h2c://127.0.0.1:8081, so gRPC works end to end over HTTP/2.
Secrets
Secrets live in [secrets], encrypted with AES-256-GCM under the key in harbor.key. Each value is encrypted with its name as associated data, so a value can't be copied under another name, and the names stay readable: a pull request that adds a secret shows which one.
navree harbor:secrets set STRIPE_SECRET # asks for the value, without echo
navree harbor:secrets set APP_KEY --generate # a new application key
echo "$TOKEN" | navree harbor:secrets set APP_OBSERVE__METRICS_TOKEN --stdin
navree harbor:secrets list # names only
navree harbor:secrets reveal APP_MAIL__TRANSPORT
navree harbor:secrets unset STRIPE_SECRET- The key never leaves your machine.
harbor.keyis in.gitignore. Share it with your team through a password manager. In CI, put its contents in theNAVREE_HARBOR_KEYenvironment variable instead. - Values are decrypted only during a deploy, on the machine running it, and written over SSH to
/etc/<app>/env, inside the script on SSH's standard input (base64-encoded), never on a command line. They never appear in Harbor's output or logs, and never in a release directory. - Rotate the key with
navree harbor:secrets rotate-key: it re-encrypts every value under a new key and writes the newharbor.key.
--stdin drops the one trailing newline echo adds, and an empty value is refused. A stored value is harbor:v1: followed by the nonce, the ciphertext and its tag in URL-safe base64; harbor.key holds base64: and the 32-byte key, the same form as an APP_KEY. Rotating a secret itself is setting it again and deploying.
Deploying
navree deployDeploying crm-api to production (1 server)
build crm-api for x86_64-unknown-linux-gnu, in a container (1m 42s)
upload app-1 releases/20261026-091512-3f2a9c1, 14.2 MB (3s)
env app-1 4 settings, 3 secrets
migrate app-1 2 migrations ran (1s)
start app-1 green on 127.0.0.1:8082
health app-1 GET /health/ready 200 after 0.4 s
switch app-1 Caddy -> green, current -> 20261026-091512-3f2a9c1
drain app-1 blue stopped after its requests finished
restart app-1 worker x2, scheduler
prune app-1 1 old release removed
Released 20261026-091512-3f2a9c1 in 1m 53s.A release id is the UTC time of the deploy and the short Git commit. Step by step:
- Build. Harbor builds the release binary for your servers' architecture: natively when the machine running the deploy is Linux on the same architecture with a glibc no newer than the servers', otherwise in a container (
build.method). The container is Harbor's builder image, Ubuntu 22.04 with a stable Rust toolchain, built once on your machine (or the image inbuild.image); its glibc is no newer than the servers', so the binary runs on 22.04 and 24.04 alike. Container builds keep Cargo's registry, the toolchain and the target directory in named volumes (navree-harbor-*), so the second build is incremental. - Upload. The binary, the directories in
deploy.include(configandresources, when present) and a.harbor-releasefile naming the commit go to every server in parallel as one compressed archive over SSH, and are unpacked into a new directory underreleases/. The directories indeploy.sharedare links toshared/, sostorage/survives releases. - Environment.
/etc/<app>/envis written from the three sources. - Migrate.
migrateruns once, from the new release, on the firstwebserver. If a migration fails, the deploy stops here and nothing else changes. - Start. The idle slot (green) is pointed at the new release and started on its own port, next to the live one.
- Health check. Harbor requests
deploy.health_check(/health/ready) on the new instance until it answers 200, for up todeploy.health_timeout_secs(60). Readiness runs every health check your modules register, the database included, so a release that can't reach its database never gets traffic. If it doesn't pass, Harbor stops the new instance, prints its last log lines, and the old release goes on serving as if nothing happened. - Switch. Caddy is pointed at the new instance and reloads gracefully: connections in flight finish on the old one. The
currentlink moves to the new release in one atomic rename. - Drain. The old slot gets SIGTERM and finishes its requests in flight (
http.shutdown_grace_secs, 30 seconds) before it exits. - Restart workers. The worker and scheduler processes restart on the new release. Each finishes the jobs it is running first.
- Prune. Releases beyond
deploy.keep_releases(5) are removed.
- releases/
- 20261026-091512-3f2a9c1/
- crm-api
- config/
- storage -> ../../shared/storage
- 20261025-163003-9b17e44/
- 20261026-091512-3f2a9c1/
- current -> releases/20261026-091512-3f2a9c1
- blue -> releases/20261025-163003-9b17e44
- green -> releases/20261026-091512-3f2a9c1
- shared/
- storage/
| Option | What it does |
|---|---|
--server <name> | Deploy to one server. |
--env <name> | Use harbor.<name>.toml; see environments. |
--no-migrate | Skip the migrations. |
--maintenance | Run your app's down on every web server before migrating and up after the switch (maintenance mode), for migrations the running release can't work with. If the deploy fails, Harbor runs up again. |
--release <id> | Deploy a release already uploaded (one navree deploy:releases lists), without building. |
Zero downtime, and what it asks of you
The old release keeps serving until the new one is healthy, and requests in flight finish where they started, so users see no errors during a deploy. Two things make that work, and they are yours to keep:
- Migrations must suit both releases. The new release migrates the database while the old one still serves. Add columns and tables freely; rename or drop one over two deploys: first stop using it, then remove it. When that isn't possible, deploy with
--maintenance. - WebSockets reconnect. Connections held by the old instance close when it drains, and Pusher clients reconnect to the new one within seconds. Real-time clients see a reconnect, not an error.
Workers and the scheduler finish their running jobs before they restart, so no job is cut off by a deploy; give your longest jobs a timeout_secs that fits in the units' stop timeout.
Rollbacks
navree deploy:rollbackRolling back crm-api on app-1 to 20261025-163003-9b17e44
start app-1 blue on 127.0.0.1:8081
health app-1 GET /health/ready 200 after 0.3 s
switch app-1 Caddy -> blue, current -> 20261025-163003-9b17e44
drain app-1 green stopped after its requests finished
restart app-1 worker x2, scheduler
Rolled back in 6s.A rollback switches to the previous release with the same start, health check, switch and drain as a deploy, without building or uploading anything. --to <release> picks an older one, and navree deploy:releases lists them:
20261026-091512-3f2a9c1 current feat: stage probabilities
20261025-163003-9b17e44 fix: deal board ordering
20261024-110741-52ad0f3 chore: dependenciesRollbacks don't undo migrations: the database stays as the newer release left it, which is why migrations must suit both releases. To undo the last batch as well, run navree harbor:run migrate:rollback before rolling back.
Several servers and roles
As your app grows, give each role its own servers:
app = "crm-api"
domain = "crm.example.com"
[deploy]
trusted_proxies = ["10.0.0.0/24"] # the load balancer's network
[servers.web-1]
host = "203.0.113.11"
private_ip = "10.0.0.11"
roles = ["web", "scheduler"]
[servers.web-2]
host = "203.0.113.12"
private_ip = "10.0.0.12"
roles = ["web"]
[servers.worker-1]
host = "203.0.113.21"
private_ip = "10.0.0.21"
roles = ["worker"]
[servers.data-1]
host = "203.0.113.31"
private_ip = "10.0.0.31"
roles = ["postgres", "redis"]- Private network. Servers with a
private_iptalk to each other over it: PostgreSQL and Redis listen on it, and the firewall admits only your servers' addresses. Without private addresses, PostgreSQL is reached over the public ones with TLS required (hostsslandsslmode=require), and Redis, which has no TLS here, must stay on the server that uses it:harbor.tomlis refused when another server needs Redis and either side lacks aprivate_ip. - One of each data server. At most one server has the
postgresrole and one theredisrole; they can be the same server. - Rolling deploys. Harbor builds once, uploads to every server in parallel and migrates once. Then it switches the web servers one at a time, each after its own health check. If one fails, Harbor switches the ones it already moved back to the previous release, so all web servers always run the same release. Workers and the scheduler restart once every web server is on the new release.
- A load balancer in front. With more than one web server, put your provider's load balancer in front, forwarding ports 80 and 443 to each one. Caddy still terminates TLS. Turn on
web.proxy_protocol = truewhen the balancer speaks the PROXY protocol, so Caddy, and then your app, see each client's address. - SQLite or PostgreSQL. SQLite suits one server. With several servers, use the
postgresrole and thepostgresfeature.
To add a server, add its table, run navree harbor:provision <name>, then navree deploy.
In a Cargo workspace of several services, each service's package directory has its own harbor.toml with its own servers or roles. Run navree deploy from that directory to release one service without touching the others.
A front end next to your API
An app with a web front end in web/, such as one made by navree new --ui next or the SaaS starter kit, deploys it in the same release. Describe it in [frontend]:
[frontend]
path = "web"
build = "pnpm install --frozen-lockfile && pnpm build"
command = "pnpm start"
health_check = "/"- Harbor builds it during the deploy's build step (natively, or in a
node:<frontend.node_version>-bookwormcontainer when the binary builds in one), uploads it with the binary into the release, and installs Node.js (frontend.node_version, 24) onwebservers when it provisions them. Withoutbuildandcommand, Harbor picks npm, pnpm or Yarn from the lockfile. - It runs in its own blue and green slots (
frontend.ports, 3001 and 3002) ascrm-api-frontend@blueand@green, with its own health check, and switches together with the API, so a page and the API it calls always come from the same release. Its environment hasPORT,HOSTNAME=127.0.0.1,NODE_ENV=productionandNAVREE_API_URL, the address of the API slot of the same release. - Caddy serves
domainfrom the front end. The API stays on loopback, where the front end's server calls it; setapi_domainto also serve the API publicly, for exampleapi.crm.example.comfor mobile clients.
Custom domains
When your workspaces bring their own domains (custom domains, DomainsModule), Caddy can serve every one of them with its own certificate, got on the domain's first visit. Turn it on:
[web.custom_domains]
enabled = true
# ask_path = "/_domains/allow" # the app's domains.ask_path
# to = "frontend" # with a [frontend]: the domains serve the front endharbor:provision adds Caddy's on-demand TLS to /etc/caddy/Caddyfile, asking the app through the internal entry, so the question always reaches the live slot:
{
# Workspaces' own domains: a certificate on each one's first visit,
# once the app's ask endpoint allows the domain.
on_demand_tls {
ask http://127.0.0.1:8079/_domains/allow
}
}
import /etc/caddy/sites/*.caddyand a site for any other host name to the app's site file:
https:// {
tls {
on_demand
}
encode zstd gzip
@metrics path /metrics
respond @metrics 404
reverse_proxy 127.0.0.1:8081
}Caddy asks the app before it gets a certificate for a host, and the app says yes only for verified domains, so nobody can make your server request certificates for names you don't serve. Point customers' CNAME records at domain (or a host name of yours with the same address) and set the app's domains.target to it. The global options belong to the server, so one app per server serves custom domains. Run navree harbor:provision again after changing the section.
Environments
Each environment has its own file: harbor.toml for production, harbor.staging.toml for staging, with its own servers, settings, secrets and key (harbor.staging.key). Pass --env to any command:
navree harbor:provision --env staging
navree deploy --env staging
navree harbor:secrets set APP_KEY --generate --env stagingNAVREE_HARBOR_ENV=staging does the same for a whole shell or CI job.
Working with your servers
| Command | What it does |
|---|---|
navree harbor:status | Each server's roles, its processes and their state, the current release, disk and memory, and whether installed updates need a reboot. |
navree harbor:logs | Your app's log lines from journald, merged across servers by time, newest last. --server, --process web (or frontend, worker, scheduler), --lines 200 (100 by default, per server) and --follow narrow them down. |
navree harbor:run <command> [args] | Runs one of your app's commands on the first web server (or --server), from the current release with its environment, on your terminal: navree harbor:run migrate:status, navree harbor:run queue:retry all. Its exit code is navree's. |
navree harbor:ssh [server] | Opens a shell on a server as the navree user (the first web server by default). |
navree harbor:backup | Runs the PostgreSQL backup now on the postgres server; --download also copies the dump to the current directory. |
app-1 203.0.113.10 web, worker, scheduler, postgres, redis
release 20261026-091512-3f2a9c1 (current), 4 older
web green, running on 127.0.0.1:8082
caddy running
worker 2 of 2 running
scheduler running
postgres running
redis running
system disk 18% of 80 GB, memory 41% of 4 GB, load 0.12Container images
Harbor servers run your binary under systemd; they don't need a container runtime. For platforms that run containers, navree build:image builds an OCI image of your app with Docker or Podman:
navree build:image
navree build:image --tag registry.example.com/crm-api:1.4.0 --push
navree build:image --platform linux/arm64Built crm-api:3f2a9c1 (linux/amd64) with docker.The tag defaults to <app>:<short commit> (<app>:latest outside Git) and the platform to your machine's architecture. build:image works with or without harbor.toml: with one, it builds app with build.features and ships deploy.include. In a Cargo workspace, the build context is the workspace root, so path dependencies build too.
The image builds in two stages, rust:1-bookworm then debian:bookworm-slim:
| Contents | The binary at /app/crm-api, and config/ and resources/ when present |
| Working directory | /app, so the binary finds config/app.toml |
| User | An unprivileged user, uid 10001 |
| Writable | /app/storage, declared as a volume |
| Environment | APP_HTTP__ADDR=0.0.0.0:8080, EXPOSE 8080 |
| Entry point | /app/crm-api, with serve as the default command |
The same image runs every process: pass the command.
docker run -p 8080:8080 --env-file production.env registry.example.com/crm-api:1.4.0
docker run --env-file production.env registry.example.com/crm-api:1.4.0 queue:work
docker run --env-file production.env registry.example.com/crm-api:1.4.0 migratenavree harbor:secrets export > production.env writes [env] and the decrypted [secrets] as an env file; keep the file out of your repository, and prefer your platform's secret store. Point the platform's liveness and readiness probes at /health/live and /health/ready.
To change the image, navree build:image --dockerfile writes the Dockerfile and .dockerignore it uses into your app; later builds use your copy. In a workspace, where the build context is the workspace root, the ignore file is Dockerfile.dockerignore next to the Dockerfile, the name BuildKit reads for that Dockerfile. Harbor never overwrites either file.
Push to deploy with GitHub Actions
navree harbor:ci github writes a workflow that tests and deploys every push to main (--branch <name> for another branch; with --env staging, the workflow deploys that environment):
name: Deploy
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-24.04
environment: production
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --locked
- run: cargo install --git https://github.com/NavreeHQ/navree navree-cli --locked
- run: navree deploy
env:
NAVREE_HARBOR_KEY: ${{ secrets.NAVREE_HARBOR_KEY }}
NAVREE_HARBOR_SSH_KEY: ${{ secrets.NAVREE_HARBOR_SSH_KEY }}Set it up once:
- Create a key pair for CI (
ssh-keygen -t ed25519 -f ci-deploy -N ""), addci-deploy.pubtossh.authorized_keys, and runnavree harbor:provisionso the servers accept it. - In the repository's settings, add two secrets:
NAVREE_HARBOR_KEY, the contents ofharbor.key, andNAVREE_HARBOR_SSH_KEY, the contents ofci-deploy.
The runner verifies each server against the host_key in harbor.toml, so it needs no known_hosts file. The concurrency group makes deploys queue one after another instead of overlapping, and a failing test stops the deploy before anything is built. When harbor.toml sits in a package directory below the repository root, the workflow is written at the root with a working-directory for the deploy step. Harbor won't overwrite an existing workflow file.
Security
- SSH. Keys only, host keys pinned in
harbor.toml,fail2banon the SSH port. Deploys use thenavreeuser, whosesudocovers only the app's units and Caddy. - Network. A deny-by-default firewall; databases reachable from your servers only; the app itself listens on loopback, behind Caddy.
- TLS. Certificates from Let's Encrypt, renewed automatically, HTTP redirected to HTTPS. Navree's own
Strict-Transport-Securityheader tells browsers to stay on HTTPS. - Secrets. Encrypted at rest in the repository, decrypted only on the deploying machine, stored on servers in a file only root and the app's user can read.
- The app's processes. Run as an unprivileged user, with systemd hardening: no new privileges, a read-only system, a private
/tmp, and write access toshared/alone. - Updates. Security updates install automatically. Harbor never reboots a server on its own;
navree harbor:statussays when one needs a reboot.
The library
The navree-harbor crate does the work behind these commands, and the navree CLI has it built in. Tools can use it too: the facade re-exports it as navree::harbor with the harbor feature. It is opt-in, not part of full, since an app's runtime never needs it:
[dependencies]
navree = { git = "https://github.com/NavreeHQ/navree", features = ["harbor"] }use std::path::Path;
use navree::harbor::{Environment, Project, Role};
fn main() -> Result<(), navree::harbor::HarborError> {
// ./harbor.toml, validated as `navree deploy` would.
let project = Project::load(Path::new("."), Environment::production())?;
for server in project.config.servers_with(Role::Web) {
println!("{} serves {}", server.name, project.config.domain.as_deref().unwrap_or(""));
}
Ok(())
}Every program Harbor runs (ssh, cargo, git, docker or podman) goes through an Executor: SystemExecutor starts real processes, and FakeExecutor records each command and answers from a script, which is how Harbor's own tests check every script it sends without a server. The release state machine runs against the Host trait: SshHost over SSH, and FakeHost in memory.
Reference
Commands
| Command | What it does |
|---|---|
navree harbor:init | Writes harbor.toml and harbor.key, and ignores the key in Git. |
navree harbor:provision [server] | Prepares every server, or one, for its roles. Safe to run again. |
navree harbor:secrets set <NAME> [--generate] [--stdin] | Encrypts and stores a secret. --generate makes an application key. |
navree harbor:secrets list, reveal <NAME>, unset <NAME> | Lists names, prints one value, removes one. |
navree harbor:secrets export | Prints [env] and the decrypted [secrets] as an env file. |
navree harbor:secrets rotate-key | Re-encrypts every secret under a new key. |
navree deploy | Builds, uploads, migrates, health-checks and switches to a new release. |
navree deploy:rollback [--to <release>] | Switches back to the previous release, or to one you name. |
navree deploy:releases | Lists the releases on the servers. |
navree harbor:status, harbor:logs, harbor:run, harbor:ssh, harbor:backup | See working with your servers. |
navree harbor:ci github [--branch <name>] | Writes the GitHub Actions workflow. |
navree build:image [--tag] [--push] [--platform] [--dockerfile] | Builds an OCI image of the app. |
Every command takes --env <name>. Harbor looks for harbor.toml in the current directory and then in the directories above it.
harbor.toml keys
| Key | Default | What it does |
|---|---|---|
app | the package next to harbor.toml | The package to build and run. |
domain | required with a web server | The domain Caddy serves and gets a certificate for. |
aliases | [] | More domains, redirected to domain. |
api_domain | none | With [frontend], a domain that serves the API publicly. |
frontend.path | none | The front end's directory; turns the front end on. |
frontend.build | npm ci && npm run build (or the pnpm or yarn equivalent for the lockfile) | How to build it. |
frontend.command | npm start (or the equivalent) | How to run it. |
frontend.health_check | / | The path that must answer 200 before a switch. |
frontend.ports | [3001, 3002] | The ports of the front end's blue and green slots. |
frontend.node_version | 24 | The Node.js major version on web servers and in container builds. |
build.method | auto | auto, native or container. |
build.features | [] | Cargo features of the build. |
build.target | your servers' architecture | A Rust target triple, to override it, such as x86_64-unknown-linux-musl. A container build adds it to the builder's toolchain first. |
build.image | Harbor's builder image | The image container builds run in. |
deploy.path | /srv/<app> | Where releases live on the servers. |
deploy.keep_releases | 5 | Releases kept for rollbacks. |
deploy.health_check | /health/ready | The path that must answer 200 before a switch. |
deploy.health_timeout_secs | 60 | How long the health check may take. |
deploy.migrate | true | Run migrate on deploy. |
deploy.include | ["config", "resources"] | Directories shipped with the binary. |
deploy.shared | ["storage"] | Directories kept across releases. |
deploy.ports | [8081, 8082] | The ports of the blue and green slots. |
deploy.internal_port | 8079 | The port of Caddy's internal entry, always the live slot. |
deploy.trusted_proxies | [] | Proxies trusted besides Caddy, such as a load balancer's network. |
processes.<role>.command | serve, queue:work, schedule:work | The command of the web, worker and scheduler processes. |
processes.worker.instances | 1 | Worker processes per worker server. |
servers.<name>.host | required | The server's public address or host name. |
servers.<name>.private_ip | none | Its address on a private network. |
servers.<name>.roles | ["web", "worker", "scheduler"] | What it runs. |
servers.<name>.host_key | written by harbor:provision | The pinned SSH host key. |
ssh.user | navree | The deploy user. |
ssh.provision_user | root | The user harbor:provision connects as. |
ssh.port | 22 | The SSH port. |
ssh.key | ~/.ssh/id_ed25519 when it exists, else your SSH agent | Your private key; NAVREE_HARBOR_SSH_KEY replaces it. |
ssh.authorized_keys | the public half of ssh.key | Public keys allowed to log in as the deploy user. |
web.metrics_allow | [] | Addresses allowed to fetch /metrics through Caddy. |
web.proxy_protocol | false | Accept the PROXY protocol from the addresses in deploy.trusted_proxies, which it needs. |
web.custom_domains.enabled | false | Serve workspaces' own domains with on-demand TLS (see custom domains). |
web.custom_domains.ask_path | /_domains/allow | The app's ask endpoint, domains.ask_path. |
web.custom_domains.to | app | Where the domains go: app, or frontend with a [frontend]. |
postgres.version | 17 | The PostgreSQL major version. |
postgres.database | the app's name with _ | The database and user name. |
postgres.backup_days | 7 | Daily backups kept. |
redis.maxmemory_mb | 256 | Redis's memory limit. |
env.<NAME> | A setting for every process. | |
secrets.<NAME> | An encrypted setting for every process. |
Unknown keys are errors, so a typo never goes unnoticed, and every command that works with your servers checks the whole file first, listing each problem it finds. The secrets commands only need the file to parse.
Environment variables
| Variable | What it does |
|---|---|
NAVREE_HARBOR_KEY | The secrets key, instead of harbor.key. |
NAVREE_HARBOR_SSH_KEY | The private SSH key, instead of ssh.key. |
NAVREE_HARBOR_ENV | The environment, instead of --env. |