Ritzy One • Deployment Guide
Install the self-contained Ritzy One application — PostgreSQL 18 + the app in two containers — on a Linux or Windows server, and configure your first company through the setup wizard.
ritzysvc account, WSL
configuration, network access from other PCs, start at boot without a sign-in, and nightly
backups.
A single, self-contained stack. No database server to install by hand, no Windows service or registry setup. Each install is standalone with its own database.
| Service | Image | Role |
|---|---|---|
postgres | postgres:18.6-bookworm | The database: one database (ritzy) holding every company, kept apart by Row-Level Security. |
app | ritzy-web-app (built from this image) | The Node API and the web UI in one process, plus LibreOffice Writer and fonts (Word-template PDFs such as quotations), a Java 11 runtime and the ZATCA fatoora SDK (Saudi e-invoicing). These extras add about 500 MB, shared by every stack on the host. |
Two volumes hold everything that must survive restarts and image updates: ritzy-pg (the PostgreSQL data, mounted at /var/lib/postgresql; the 18 image keeps it in 18/docker under that) and ritzy-data, mounted in the app at /ritzy (company backups, licence files, the ZATCA SDK and certificates).
C.UTF-8 locale (17 and later), which sorts by code point — the order
Ritzy has always used — and sorts the same on every operating system.
PG_IMAGE in .env pins the exact image; stay on 18.x, because a
different major version cannot open the volume's data.
| Component | Memory |
|---|---|
| Linux + Docker | ~1 GB |
| App (Node) | ~0.3–0.5 GB |
| PostgreSQL | ~1–1.5 GB |
| Comfortable total | ~3 GB, 2 vCPU, SSD/NVMe |
| While building the image | 4 GB minimum — see below |
%UserProfile%\.wslconfig:
[wsl2] memory=6GBthen run
wsl --shutdown from PowerShell. Servers that only pull a
published image are unaffected.
On a blank Ubuntu (including a fresh WSL2 Ubuntu) a single script does everything in sections 4–11 — prerequisites, Docker Engine, secrets, the build, and a health check:
# from the repository root, NOT from docker/
bash install-linux.sh
| Option | Effect |
|---|---|
-y | Unattended — accept every prompt. |
--port 9090 | Publish the app on a different port. |
It is safe to re-run: satisfied steps are skipped and an existing .env is never overwritten, so your database passwords and token secret survive.
The script also handles things that are easy to get wrong by hand: it starts Docker via systemd or the sysvinit wrapper (a fresh WSL has no systemd), uses sudo for the current run so a brand-new Docker install needs no logout, warns before the slow failures (too little RAM, a port already in use), and offers to move the source off /mnt first — building on the Windows drive through the WSL bridge is several times slower.
Prefer to understand each step, or installing on Windows? Continue with §4.
# Install Docker Engine + Compose plugin curl -fsSL https://get.docker.com | sh # Start it and enable on boot sudo systemctl enable --now docker # Run docker without sudo (log out and back in afterwards) sudo usermod -aG docker $USER # Verify docker --version docker compose version
wsl --install -d Ubuntu
/etc/wsl.conf containing:
[boot] systemd=truethen
wsl --shutdown from PowerShell and reopen Ubuntu.docker run --rm hello-worldhttp://localhost:8080/ works in a Windows browser. To reach it from
other machines you also need a Windows firewall rule for that port.
docker info should succeed.powershell -ExecutionPolicy Bypass -File .\install.ps1 from the docker\ folder.A first install builds the image from source, so the project has to be on the server.
cd ~ git clone <your-repo-url> ritzyweb cd ritzyweb
/mnt/cgh auth login, a
personal access token in place of the password, or an SSH key
(ssh-keygen -t ed25519, then add the .pub half at
Settings → SSH and GPG keys). If port 22 is blocked on your network, put
Host github.com / Hostname ssh.github.com / Port 443 in ~/.ssh/config.
ZATCA e-invoicing is part of the standard image, and ZATCA only offers its SDK through a
captcha-protected download, so it can't be fetched during the build. Download the
Java SDK zip from https://sandbox.zatca.gov.sa/downloadSDK and put
it in docker/vendor/:
cp ~/Downloads/zatca-einvoicing-sdk-*.zip ~/ritzyweb/docker/vendor/
The build stops with a clear message if the zip is missing. The zip is never committed to git, and servers that pull a published image (§16) need nothing here. On first start each stack copies the SDK into its data volume, so the company's ZATCA certificates survive upgrades and are included in backups.
Nothing to do by hand. The postgres container starts with the superuser password from PG_PASSWORD; on its first start the app connects with it and creates, in order:
| Step | What |
|---|---|
| Database | ritzy, with the code-point sort order |
| Schema | Every table, with Row-Level Security per company |
| Upgrades | Any numbered upgrade not yet applied — also on every later start, so an image update upgrades the database by itself |
| App login | ritzy_app with the password from PG_APP_PASSWORD: not a superuser and not the owner, so Row-Level Security applies to every request |
Each step leaves what is already there alone. docker compose logs app shows the lines (created database ritzy, applying schema.sql) the first time.
.env)cp .env.example .env
| Key | Meaning |
|---|---|
PG_PASSWORD | The postgres superuser. The app uses it on start to make the database ready. Letters and digits only (it goes into a URL). |
PG_APP_PASSWORD | The ritzy_app login every request runs as. Letters and digits only. |
JWT_SECRET | Long random string that signs login tokens. Unique per install. |
APP_PORT | Host port you browse to. Default 8080. |
PG_IMAGE | PostgreSQL image. Default postgres:18.6-bookworm; stay on 18.x (another major cannot read the ritzy-pg data). |
RITZY_IMAGE | App image. A local tag (the default) means “build from source”; a registry reference means “pull”. |
PG_SHARED_BUFFERS / PG_WORK_MEM / PG_MAX_CONNECTIONS | PostgreSQL tuning. Shared buffers about a quarter of PG_MEM_LIMIT. |
install-linux.sh, ./install.sh and install.ps1 all
generate strong PG_PASSWORD, PG_APP_PASSWORD and JWT_SECRET values on first run, and
never touch an existing .env.
.env safe, and never commit itPG_PASSWORD later
locks the app out until the old one is put back; changing JWT_SECRET signs
everyone out.
No editing of docker-compose.yml is required. While RITZY_IMAGE is a local tag the install scripts layer docker-compose.build.yml on top to build from source; point it at a published image and they pull instead.
# build from source (first install) docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build # or, with a published image in RITZY_IMAGE docker compose pull && docker compose up -d docker compose ps # both services should reach "healthy" docker compose logs -f app # watch startup; Ctrl-C to stop watching
The first build compiles the API and three front-ends — 5–10 minutes is normal. Later starts are immediate.
The app service waits for postgres to report healthy (pg_isready), then makes the database ready (§7) before it starts serving.
localhost on the same machine).curl http://localhost:8080/api/version # {"version":"<build marker>"} curl http://localhost:8080/api/setup/status # before setup: {"needsSetup":true,...} # after setup: {"needsSetup":false,...}
Persistence — the data must survive a restart:
docker compose down && docker compose up -d
The company and your login should still be there; data lives in the ritzy-pg and ritzy-data volumes, not in the containers.
| Port | Service | Notes |
|---|---|---|
| 8080 | Docker app (API + web UI) | APP_PORT. The only port this bundle publishes. |
| 5432 | PostgreSQL | Not published — reachable only from the app container. |
| 80 | Web dashboard, native install | nginx, non-Docker installs. |
| 8081 | Ritzy Track PWA, native install | Was 8080 before the renumber. |
| 8082 | Ritzy Dues PWA, native install | Was 8081 before the renumber. |
install.ps1 before starting the container —
otherwise nginx still holds 8080 and the container cannot bind it. That re-run also
rewrites the PWA URLs in KEYPAIRS, but only where they still hold the
installer's own old default; anything set by hand must be updated yourself, and phones with
the PWA already installed keep the old URL until the QR code is re-scanned.
docker compose pull && docker compose up -d
# building locally instead:
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
Set RITZY_IMAGE in .env back to the previous version tag, then docker compose up -d.
docker compose down # stop; data persists in the volume docker compose up -d # start again
down -v destroys every company-v flag deletes the ritzy-pg and ritzy-data volumes
along with the containers. It is the right command to start a clean test from nothing, and a disaster
anywhere else. There is no undo.
backup-stacks.shpg_dump.One script backs up every stack on the host: a pg_dump -Fc of the
stack's database (ritzy.pgdump, every company), read from one snapshot so it is
consistent while people work. It also saves the app's volume (files.tar.gz: company
backups, ZATCA certificates, licence files) and the stack's .env.
cd ~/ritzyweb/docker ./backup-stacks.sh --rclone storagebox:ritzy # or --rsync user@host:path, or --rsync /mnt/d/Backups # daily at 02:30 (crontab -e) 30 2 * * * $HOME/ritzyweb/docker/backup-stacks.sh --rclone storagebox:ritzy >> $HOME/ritzy-backups/backup.log 2>&1
| Option | Effect |
|---|---|
--rclone remote:path / --rsync dest | Copy the backups off the server after each run. Without one, the script warns: a backup on the same disk as the data is not a backup. |
--keep 30 | Days kept locally (default 14). |
--dest /path | Local backup folder (default ~/ritzy-backups). |
--stack ~/ritzy-acme | Only this stack (repeatable). |
Each run writes <dest>/<stack>/<date>/. It exits with an error if any stack or the off-site copy failed, and never leaves a partial file that could pass for a good backup.
One company: DBA Console → Backup / Restore. A .pgbk holds one company's rows and restores into the same company (a pre-restore copy is written first) or as a new one.
The whole stack from a nightly backup — stop the app, restore, start it:
cd ~/ritzy-acme docker compose stop app docker compose exec -T postgres pg_restore -U postgres --clean --if-exists -d ritzy < ~/ritzy-backups/acme/<date>/ritzy.pgdump docker compose start app
These are different things, and the wrong choice is expensive to undo.
| Another company in one stack | Another stack | |
|---|---|---|
| How | DBA Console → Add Company | A second copy of the bundle, its own .env |
| Databases | One database, companies kept apart by Row-Level Security | Separate volumes, separate PostgreSQL |
| Users & logins | Chosen from a company list at login | Completely separate; no shared users |
| Resources | One PostgreSQL, one Node process | Everything doubles |
| Upgrades | All companies move together | Independent — upgrade one, hold the other |
| Use it for | Branches, divisions, group entities | Separate customers, or prod vs a test copy |
new-stack.shOne command per additional customer. It creates the folder, generates that stack's own secrets, sets the project name and port, and refuses the mistakes that are painful later — a port already in use, a project name already running, or an image that does not exist yet:
cd ~/ritzyweb/docker # build the shared image once docker compose -f docker-compose.yml -f docker-compose.build.yml build # then one command per stack ./new-stack.sh acme 8090 ./new-stack.sh globex 8091 --start
Each lands in ~/ritzy-<name> with its own .env. Start it with docker compose up -d from that folder (or pass --start), then run the first-company wizard on its port. The rest of this section explains what the script sets up, and how to do it by hand.
Compose names everything — containers, networks and the data volume — after its project, which defaults to the folder name. Give each stack its own folder and the two never touch:
cp -r ~/ritzyweb/docker ~/ritzy-acme cp -r ~/ritzyweb/docker ~/ritzy-globex
Then in each .env, three keys must differ:
| Key | ~/ritzy-acme/.env | ~/ritzy-globex/.env |
|---|---|---|
COMPOSE_PROJECT_NAME | acme | globex |
APP_PORT | 8080 | 8090 |
PG_PASSWORD / PG_APP_PASSWORD / JWT_SECRET | its own set | its own set |
RITZY_IMAGE | the same value — both run one shared image (see below) | |
Setting COMPOSE_PROJECT_NAME explicitly is worth the line: it survives a folder rename, and it is what appears in every container and volume name.
cd ~/ritzy-acme && docker compose up -d cd ~/ritzy-globex && docker compose up -d docker volume ls | grep ritzy-data # acme_ritzy-data <- separate volumes (and acme_ritzy-pg / globex_ritzy-pg) # globex_ritzy-data
Only APP_PORT is published, so that is the one number that has to be unique. PostgreSQL is not published at all — each stack's database is reachable only by its own app container, which is exactly the isolation you want.
docker compose down -v run from the wrong directory destroys the wrong
customer's databases. Check with docker compose ps first — the container
names carry the project prefix — or be explicit:
docker compose -p acme down.
docker/. A stand-alone copy has no repository above it, so
--build there fails. Build in the source checkout, then point the copies at
the resulting image. (This is why new-stack.sh checks the image exists before
creating anything, and why it does not copy the build override file.)
Do not rebuild per stack. Build once, tag it, and point every .env at that tag:
# once, from the repository root cd ~/ritzyweb/docker docker compose -f docker-compose.yml -f docker-compose.build.yml build docker tag ritzy-web-app:local ritzy-web-app:1.0.0 # then in every .env RITZY_IMAGE=ritzy-web-app:1.0.0
Containers share the image's disk layers, so a second stack costs almost no extra disk — only its data. It also means an upgrade is deliberate per stack: build a new tag, change that stack's RITZY_IMAGE, docker compose up -d. The others stay where they are until you move them.
Every stack has memory and CPU ceilings (PG_MEM_LIMIT, PG_CPUS,
APP_MEM_LIMIT, APP_CPUS in its .env), so one customer's
heavy report cannot starve the others. new-stack.sh --size writes a matching set:
| Size | For | PostgreSQL shared buffers / ceiling | App ceiling | Total |
|---|---|---|---|---|
small | 1 company, trials | 192 MB / 768 MB, 1 CPU | 640 MB, 1 CPU | ~1.4 GB |
standard (default) | 2–3 companies | 384 MB / 1.5 GB, 2 CPU | 1 GB, 2 CPU | ~2.5 GB |
large | 3–5 companies | 1 GB / 4 GB, 4 CPU | 1.5 GB, 2 CPU | ~5.5 GB |
./new-stack.sh tiny 8093 --size small --start
PostgreSQL's ceiling must cover its shared buffers plus PG_WORK_MEM per running sort and some headroom — keep shared buffers near a quarter of PG_MEM_LIMIT. Too low and the server is killed mid-query. A CPU value above the host's core count stops the container from starting. To resize a stack, edit its .env and run docker compose up -d.
Each stack runs its own PostgreSQL, so memory multiplies. Reckon on roughly 2–2.5 GB per stack beyond the host's own ~1 GB. Unlike the old per-database Firebird cache, a stack's shared buffers serve all its companies at once, so adding a company does not add a cache — but adding a stack does. Use --size small when packing several stacks onto one machine, or the host starts swapping and every stack feels slow at once.
Ports are fine internally, but customers want a hostname. Put a reverse proxy in front — each stack keeps its own port on 127.0.0.1 and the proxy maps names to them:
acme.example.com → 127.0.0.1:8080 globex.example.com → 127.0.0.1:8090
Caddy, nginx or Traefik all do this in a few lines, and that is also where TLS belongs — the bundle itself serves plain HTTP.
backup-stacks.sh (§13) already covers every ~/ritzy-* stack, each in its own folder named after the stack, so a restore cannot go to the wrong customer.
For web-only customers on a Windows installation, whose companies are still in Firebird .FDB files. They are converted to PostgreSQL on the Windows side by the importer (npm run pg:convert), then restored into the stack. Customers who also use the Ritzy 7 desktop app stay on Windows — it needs the Firebird databases.
gbak / isql (already there), Node.js 20+, a RitzyWeb source checkout (the importer is a development script) and a PostgreSQL 18 to convert into — a local one is simplest. For the final move, stop the Ritzy One service first:
powershell -ExecutionPolicy Bypass -File docker\migrate\export-windows.ps1 -PgUrl postgres://postgres:<pw>@localhost:5432/postgres
# -InstallDir if not C:\RitzyOne
It converts every company in CompLib.xml (and its documents database) into a scratch database, which it drops afterwards, and writes one zip: a pg_dump of the result, the conversion report, a SHA-256 checksum, the settings that must follow the customer (AI key, web-push keys, licensing — never a database password), the licence files and the ZATCA certificates.cd ~/ritzyweb/docker
./new-stack.sh acme 8090 --start # if the stack is new
./migrate/import-to-stack.sh ritzy-migration-<date>.zip ~/ritzy-acme
It checks the checksum, restores the dump into the stack's database, clears the Windows ZATCA SDK folder setting, installs the certificates, licence and settings, and restarts the app — which applies any newer upgrades. It refuses to replace a stack that already has companies unless you add --force (the old database is kept as ritzy_pre_import_<date>); companies are not merged.Afterwards: users sign in again (the stack has its own sign-in secret); read the conversion report left in the stack folder (pg-validation-<date>.md); check that every company opens, that ZATCA shows the certificate as installed, and that the licence is valid; run backup-stacks.sh; stop the old Windows service; delete the zip.
Rather than building on every server, build the image once and publish it to a registry:
# on a build machine, once, logged in to the registry
docker login ghcr.io
OWNER=your-github-org VERSION=1.0.0 ./docker/build-and-push.sh
Each server then needs only the docker/ folder and a .env with
RITZY_IMAGE=ghcr.io/<owner>/ritzy-web-app:1.0.0, and runs
docker compose up -d — no source checkout, no build, and a much smaller
machine will do.
| Symptom | Cause & fix |
|---|---|
The app restarts in a loop: password authentication failed for user "postgres". |
PG_PASSWORD was changed after the ritzy-pg volume was created; PostgreSQL keeps the password it first started with. Put the old one back — or, on a stack with no data yet, docker compose down -v and start again. |
The postgres service never becomes healthy. |
docker compose logs postgres. Usually PG_MEM_LIMIT too low for PG_SHARED_BUFFERS, or a full disk. |
Permission denied writing under /ritzy. |
The app volume is not writable by the app. The entrypoint hands the directories over at start-up; check whether it warned:docker compose logs app | grep entrypoint |
Image build fails during npm ci. |
Every workspace manifest must be present for the lockfile to validate. If you have trimmed the source tree, restore the full packages/*/package.json set. |
Build stops: put the ZATCA fatoora SDK zip in docker/vendor/. |
The SDK zip is missing — see §6. For a development build without e-invoicing only, pass --build-arg ZATCA_SDK_OPTIONAL=1. |
A stack won't start: range of CPUs is from 0.01 to … |
Its CPU ceiling is above the host's cores. Lower PG_CPUS / APP_CPUS in that stack's .env, or use --size small (§14). |
| Build dies with no clear error, often at the front-end step. | Out of memory. Give the machine (or WSL) at least 4 GB — see §2. |
port is already allocated on start. |
Something else holds APP_PORT — commonly an nginx from a native install still on 8080 (§12). Change APP_PORT, or re-run the native installer to move its PWAs. |
| Docker is gone after restarting WSL. | systemd is not enabled. Create /etc/wsl.conf with [boot] / systemd=true, then wsl --shutdown (§5). |
| Browser cannot reach the app. | Check docker compose ps (both healthy), the APP_PORT mapping, and any host firewall. From another machine on the LAN a Windows firewall rule is required even though localhost works. |
Windows install.ps1 fails with odd parse errors. |
Windows PowerShell 5.1 reads a script that has no UTF-8 byte-order mark as ANSI, and an em dash then decodes into a quote character that ends a string mid-comment. Save the file as UTF-8 with BOM, or run it under PowerShell 7 (pwsh). |
docker/README.md and docker/INSTALL.md in the project for the operational reference and the exact items to confirm on a new Docker host.