Deploy
Provision PostgreSQL, the Rust app and Caddy for HTTPS on a VPS. You need a supported Linux host, administrative access, DNS and open ports 80/443. Use an explicitly selected published CLI/image version; source-only features require their own release and acceptance. Node is not required.
curl -fsSL https://raw.githubusercontent.com/Nelcota/Nelcota/main/scripts/install.sh | sh # binary (with checksum) + Docker if missing
mkdir -p /opt/nelcota && cd /opt/nelcota
nelcota init api.yourdomain.com # creates the host and the first project
nelcota up # starts projects + Caddy, waits for the healthcheck
nelcota doctor # optional: checks DNS, ports, backups and healthFirst create a DNS A record for the domain pointing at the VPS IP. Caddy
issues the certificate on its own on the first request. After starting
everything, up polls https://<domain>/health for up to a minute; if the
public URL does not answer, it lists what to check (DNS, firewall, Caddy logs)
and exits non-zero. Local hosts (--local) skip this check.
Without curl | sh: download nelcota-<architecture> and
nelcota-<architecture>.sha256 from the releases page, run sha256sum -c and
copy it to /usr/local/bin. The script does just
that.
The built-in database connector uses private-network NoTls; an sslmode
URL is not a TLS connector. Keep database ports private. Production secrets
should be stable/private; do not use dev credentials or service tokens in apps.
Host and projects
A VPS can hold several projects, each one isolated: its own Postgres, users, JWT keys, panel and backups. A single project is simply a host with one project.
Internet ──80/443──▶ Caddy (shared)
├── api.shop.com ──▶ app-shop ──▶ shop's Postgres (its own internal network)
└── blog.example.com ──▶ app-blog ──▶ blog's Postgres/opt/nelcota/ (the host folder; -C <folder> or NELCOTA_ROOT)
├── nelcota-host.json registry: login mode, base domain, projects (no secrets)
├── host.env host secrets (0600): admin, SSO secret, S3
├── shared/projects.json public list (name + URL), mounted into the apps for the switcher
├── caddy/ shared Caddy; the Caddyfile is generated from the registry
├── archive/ final backups of removed projects
└── projects/<name>/ docker-compose.yml, .env (0600), migrations/, backups/- Only Caddy publishes ports. Each project's Postgres sits on its own
internal: truenetwork; apps talk to Caddy over thenelcota_edgenetwork. Postgres also joins anegressnetwork of its own, outbound only, to archive WAL to S3 when PITR is on. - One Postgres per project, not a shared one: Postgres roles (
anon,authenticated,authenticator...) apply to the whole server, and separate servers keep the physical isolation and a per-projectpg_dumpfor leaving.
Own domain or subdomain
nelcota init api.shop.com # own domain → project "shop"
nelcota init api.shop.com --project sales # explicit name
nelcota init --project blog --base-domain example.com # subdomain: blog.example.com
nelcota init --project panel # uses the base domain already saved on the host
nelcota init --local --project shop # local: https://shop.localhostThe project name comes from the domain (api.shop.com → shop), or from
--project. It shows up in the panel's project switcher and in the commands'
-p.
Arguments are validated before provisioning. A package, filesystem or service failure during provisioning can still leave files/services behind; inspect the reported stage before retrying.
init flags
| Flag | What it does |
|---|---|
--project <name> |
project name (default: derived from the domain) |
--base-domain <domain> |
saves the host's base domain: --project shop becomes shop.<base> |
--local |
local host without a public domain (https://<project>.localhost, internal certificate); only when the host is created |
--panel-login shared|per-project |
panel login mode when the host is created (default shared) |
--email <email> |
administrator email (default admin@<domain>) |
-y, --yes |
asks no questions (defaults and flags only) |
--image <image> |
app image (default ghcr.io/nelcota/nelcota-server, or NELCOTA_IMAGE) |
--version <tag> |
image tag (default: the CLI's version) |
--profile 1gb|2gb|4gb|8gb |
Postgres profile (default: by the RAM split across the projects) |
--s3-endpoint, --s3-bucket, --s3-access-key, --s3-secret-key, --s3-region |
backup bucket, written to host.env (all four or none; region defaults to us-east-1). Without them, an interactive init asks, reading the secret key without echo |
--runtime docker|systemd |
how projects run (only when the host is created; see below) |
--firewall |
configures ufw (SSH, 80 and 443) |
--skip-checks |
skips the Docker, ports, RAM and DNS checks |
Commands
With a single project, -p is optional. With several, commands that act on a
project ask for -p <name>.
| Command | What it does |
|---|---|
nelcota projects |
lists projects, state and version |
nelcota up |
starts every project and Caddy (-p for just one), then checks public HTTPS |
nelcota status |
app and Postgres health, last backup age, version and free disk |
nelcota doctor |
checks Docker, Caddy/ports 80 and 443, free disk, DNS, the backup cron, a backup younger than 26 h per project, S3 and service health; exits non-zero on any problem |
nelcota -p shop logs -f [app|postgres] / logs caddy |
project logs / the shared proxy's logs |
nelcota -p shop down / down --all |
stops (--volumes DELETES the data after a confirmation, --yes skips it; Docker only) |
nelcota -p shop migrate |
applies projects/shop/migrations/V<n>__<name>.sql |
nelcota -p shop types -o database.ts / types --lang rust -o database.rs |
TypeScript (default) or Rust types of the schema |
nelcota -p shop token service-role |
service JWT (bypasses RLS) |
nelcota -p shop backup / backup --all --keep 7 |
dump into backups/, uploaded to S3 when host.env has a bucket (Backup and restore) |
nelcota -p shop restore <file> |
restores a dump, after a safety backup of the current state |
nelcota -p shop pitr enable / status / restore --time ... |
point-in-time recovery with WAL archived to S3 (Backup and restore) |
nelcota -p shop upgrade / upgrade --all |
upgrades with a backup and automatic rollback (--dry-run shows current → target; downgrades need --allow-downgrade) |
nelcota -p shop remove |
final backup in archive/ (starting Postgres if needed; --no-backup skips it), removes containers, data and the Caddy site |
nelcota panel-login shared / per-project |
panel login: single or per project |
nelcota admin-password |
new panel password (-p with per-project login) |
On a host, migrate, types and token run inside the project's app
container, which is what can reach Postgres. Outside a host, they use
NELCOTA_DATABASE_URL.
Without Docker (systemd)
On a small VPS that will only ever hold one project, Nelcota can skip Docker:
curl -fsSL https://raw.githubusercontent.com/Nelcota/Nelcota/main/scripts/install.sh | NELCOTA_SKIP_DOCKER=1 sh
mkdir -p /opt/nelcota && cd /opt/nelcota
nelcota init api.yourdomain.com --runtime systemd
nelcota upAs root on Debian or Ubuntu, init installs Postgres 17 and pgBackRest from
the PostgreSQL project's apt repository (PGDG) and Caddy from its own, then:
| What | Where |
|---|---|
| Postgres profile (by RAM) | /etc/postgresql/17/main/conf.d/nelcota.conf |
| The app | nelcota-<name>.service, running /usr/local/lib/nelcota/nelcota serve on 127.0.0.1:8000 with the project's .env |
| Caddy | the caddy service, /etc/caddy/Caddyfile generated from the registry |
| PITR settings | /etc/pgbackrest/pgbackrest.conf (with pitr enable) |
- Install AWS CLI v2 on the host before using offsite dump/file backups. Native package provisioning installs Postgres, pgBackRest and Caddy, not AWS CLI.
- Every command works the same way (
up,down,status,logs,migrate,backup,restore,pitr,upgrade,remove); they drivesystemctl,journalctlandrunuser -u postgresinstead ofdocker compose. - One project per machine: a second
initis refused, including from another directory and with--skip-checks. A machine-wide lease serializes native init/removal;/var/lib/nelcota-native/owner.jsonkeeps the host-directory claim across interrupted commands. Only a completely successfulremovereleases it. Do not delete this record to bypass an interrupted installation: inspect and recover the original host over SSH. Legacy installations without a record are adopted only when their existing unit points unambiguously to the selected host's absolute.envpath; conflicting or incomplete state requires explicit recovery. For multiple projects, use Docker. - Native PostgreSQL uses the local
17/maincluster on port 5432. A differently configured17/main, or another PostgreSQL cluster reserving 5432, is rejected before packages or new host/project configuration are written, including with--skip-checks. Other ports are not selected by native database commands. Native administrative clients explicitly select this cluster and local socket; ambientPGCLUSTER,PGHOST,PGHOSTADDR,PGPORTor service settings cannot redirect a backup, restore or credential update to a different database. - The app's unit runs as a throwaway user (
DynamicUser) with no write access to the system (ProtectSystem=strict) and no capabilities. upgradeswaps the binary the unit runs for the one you are running (so install the new version first:NELCOTA_VERSION=x.y.z install.sh), keeps the previous one and, if the app does not become healthy, puts it back and restores the pre-upgrade backup.removetakes the final backup, then deletes the unit and the Postgres cluster with its data (pg_dropcluster).down --volumesis Docker-only.
Panel login
- Single (default): one email and password for every panel on the host. Signing in to one panel and switching projects with the switcher does not ask for the password again.
- Per project: each panel has its own credentials; the switcher just takes you to the other panel, which asks for its own login.
nelcota panel-login per-project # generates and shows one password per project
nelcota panel-login shared # back to single login (host password)Security details in Panel.
What the first init does
- Checks Docker and the compose plugin (a user outside the
dockergroup is told to use sudo orusermod -aG docker $USER), free ports 80/443 (in use = error, with how to find the process;--skip-checkscontinues anyway), RAM and the domain's DNS. - Generates the host secrets: admin credentials (password shown once;
host.envkeeps only the argon2id hash) and the single sign-on secret. - Backup: asks for the S3-compatible destination (or use
--s3-*) and, as root, installs the daily cron job for every project (03:00, keeps 7). The job uploads wheneverhost.envhas a bucket, so S3 can be added later. - Machine: with
--firewall, configuresufw(SSH, 80 and 443).
Each later init only creates the project: its own secrets (Postgres,
authenticator, Ed25519 key), a Postgres profile from the RAM split between
projects, and the Caddy site (reloaded without taking the others down).
Project capacity depends on queries, data, hashing, uploads and PostgreSQL working sets. Select a profile, measure your workload and monitor headroom. The benchmark guide records specific fixtures; it does not establish a projects-per-GB guarantee.
Migrations
Plain SQL in projects/<name>/migrations/, named V1__create_tables.sql,
V2__indexes.sql... Each file runs in a transaction. Tracking lives in
nelcota.user_migrations (version, name, date, checksum): if an already
applied file is edited, migrate refuses. After applying, the API reloads the
catalog on its own.
Changes made in the panel
Tables, columns and policies created in the panel go straight to the project's
database, but they do not exist in any other environment (staging, another
machine, a fresh database) until they become a file. The panel records each
one and lists the pending ones under Migrations; "Generate migration"
downloads V<n>__<name>.sql with them and already registers it as applied in
this database, with the same checksum migrate computes. Put the file in
migrations/ and commit it: nothing runs again here, and migrate applies it
in the other environments.
The chosen number goes past everything in the database and in the folder (the
container reads ./migrations, mounted at /migrations), so a file not yet
applied is never overtaken. Outside the container, point to the folder with
NELCOTA_MIGRATIONS_DIR. What you run in the SQL editor is not recorded: copy
that SQL into a migration by hand.
Upgrade
nelcota -p shop upgrade makes the target image available before changing the
running project. It then puts that project's Caddy site in maintenance (HTTP
503 with Retry-After), stops the app to drain existing requests, and captures
the database and disk files together. Only then does it change
NELCOTA_VERSION in .env, start the new version and wait for its healthcheck.
If the new version does not become healthy, the CLI restores the previous
version, database and paired disk snapshot before reopening traffic. If backup
creation fails, it restarts the unchanged previous version. A failed or
interrupted recovery leaves the maintenance gate closed; after fixing the
cause, nelcota -p shop up checks health and reopens it. Other projects keep
serving traffic; upgrade --all upgrades one at a time, keeps going when one
project fails (it was rolled back) and lists the failures at the end.
Without --version, the target is the CLI's own version. The CLI compares it
with the project's NELCOTA_VERSION first:
- same version: nothing to do (
--reinstallredeploys it anyway, e.g. a rebuilt binary on a systemd host); - older target: refused, because the older app does not know the newer
migrations. When the CLI itself is the older one, reinstall it
(
curl -fsSL https://raw.githubusercontent.com/Nelcota/Nelcota/main/scripts/install.sh | sh); to go back on purpose, pass--allow-downgrade; --dry-runprintscurrent → targetfor each project and changes nothing.
nelcota upgrade --all --dry-run
nelcota -p shop upgrade --version 0.4.0 # published target; already-current projects are skippedLocal development
nelcota dev # Postgres 17 in a container (127.0.0.1:54322) + server on :8000The development secrets (panel and Postgres passwords, JWT key) live in
.nelcota/dev.env, readable only by you; the startup banner says where they
are instead of printing them. nelcota token service-role --days 30 issues a
service_role token for that environment.
--db-port <port> moves the development Postgres to another local port. The
new port is saved in .nelcota/dev.env; it gets its own container and data,
and the old container is left as it was.
To test the full deploy locally (several projects, Caddy, HTTPS with an
internal certificate): nelcota init --local --project shop and open
https://shop.localhost/admin/.
Acceptance test
ACCEPT_DOMAIN=api.example.com ./scripts/acceptance.sh # real VM
./scripts/acceptance.sh --local # local simulationLast local run (2026-10-06, Windows 11 + Docker Desktop, cached images): two
projects with HTTPS in 28 s. Also passing: migrate (idempotent), signup and
RLS through Caddy, isolation between projects (one project's JWT is not valid
on the other; one project's tables do not exist on the other), single sign-on
between panels with a single-use token, switching to per-project login and
back, types, backup → write → restore, automatic upgrade rollback without
affecting the other project, and project removal with a backup in archive/.
This dated local run is historical evidence. It does not validate a current VPS, public DNS/TLS, the native systemd runtime or controlled updates. Published releases now exist; run acceptance on your target host/version before relying on production behavior.
Optional host management
The Unreleased controller can provision and manage isolated Docker projects
after explicit SSH enrollment. Approve an official image, domain scope and VPS
addresses before enabling creation; inspect operation reviews and retain
recovery access. See docs/host-management.md for supported layouts and
production acceptance.