Ritzy One • Deployment Guide

Docker Installation 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.

Applies to the Ritzy One Docker bundle (docker/)  |  Audience: IT / System administrators
Status
The stack moved from Firebird to PostgreSQL in October 2026 and runs on PostgreSQL 18. The app's start-up (database, schema, upgrades, its own login), first-run setup and company backup / restore are verified against PostgreSQL; a complete build → wizard → login on a clean Docker host has not yet been signed off. Do the first install on a throw-away server, and read §17 if a step stalls.
Hosting on Windows Server 2025?
Follow the Docker on Windows Server 2025 guide. It covers what a server needs on top of this one: a dedicated ritzysvc account, WSL configuration, network access from other PCs, start at boot without a sign-in, and nightly backups.

1 What you are installing

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.

ServiceImageRole
postgrespostgres:18.6-bookwormThe database: one database (ritzy) holding every company, kept apart by Row-Level Security.
appritzy-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).

Why PostgreSQL 18
PostgreSQL 18 is supported until about November 2030. The database is created with the builtin 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.

What is not included in v1

2 Prerequisites & sizing

Rough sizing (per install)

ComponentMemory
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 image4 GB minimum — see below
Building needs more RAM than running
The first install compiles the API and three single-page apps. Under roughly 4 GB the front-end build is killed by the kernel out-of-memory reaper and the build fails with no obvious error. On WSL, raise it in %UserProfile%\.wslconfig:
[wsl2]
memory=6GB
then run wsl --shutdown from PowerShell. Servers that only pull a published image are unaffected.
Disk matters
PostgreSQL is I/O-sensitive — put the data on SSD/NVMe, not spinning or heavily oversubscribed storage. Allow 8 GB free for images plus the build.

3 The one-command install

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
OptionEffect
-yUnattended — accept every prompt.
--port 9090Publish 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.

4 Docker by hand — Linux

# 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

5 Docker by hand — Windows

On Windows Server, use the server guide
The steps below suit a workstation. A server also needs a dedicated account, mirrored networking, firewall rules and a startup task — see Docker on Windows Server 2025. Docker Desktop is not supported on Windows Server.

Recommended: Docker Engine inside WSL2 Ubuntu

  1. In PowerShell as Administrator, install WSL2 with Ubuntu (reboot if prompted):
    wsl --install -d Ubuntu
  2. Open the Ubuntu shell and follow the Linux steps in §4.
  3. Turn on systemd so Docker restarts with WSL — create /etc/wsl.conf containing:
    [boot]
    systemd=true
    then wsl --shutdown from PowerShell and reopen Ubuntu.
  4. Verify inside Ubuntu: docker run --rm hello-world
Published ports reach Windows
WSL2 forwards published container ports to the Windows host, so http://localhost:8080/ works in a Windows browser. To reach it from other machines you also need a Windows firewall rule for that port.

Alternative: Docker Desktop

  1. Install Docker Desktop and choose the WSL2 backend.
  2. Launch it once so the engine starts.
  3. Verify in PowerShell: docker info should succeed.
  4. Run powershell -ExecutionPolicy Bypass -File .\install.ps1 from the docker\ folder.

6 Get the application onto the server

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
Clone into the Linux home directory, not /mnt/c
Building from the Windows drive goes through the WSL 9p file-system bridge: several times slower, and npm can trip over the permissions it reports.
GitHub asks for a password and rejects it
GitHub no longer accepts account passwords for Git. Use gh 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.

Add the ZATCA SDK zip — required

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.

7 How the database is set up

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:

StepWhat
Databaseritzy, with the code-point sort order
SchemaEvery table, with Row-Level Security per company
UpgradesAny numbered upgrade not yet applied — also on every later start, so an image update upgrades the database by itself
App loginritzy_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.

8 Configure (.env)

cp .env.example .env
KeyMeaning
PG_PASSWORDThe postgres superuser. The app uses it on start to make the database ready. Letters and digits only (it goes into a URL).
PG_APP_PASSWORDThe ritzy_app login every request runs as. Letters and digits only.
JWT_SECRETLong random string that signs login tokens. Unique per install.
APP_PORTHost port you browse to. Default 8080.
PG_IMAGEPostgreSQL image. Default postgres:18.6-bookworm; stay on 18.x (another major cannot read the ritzy-pg data).
RITZY_IMAGEApp image. A local tag (the default) means “build from source”; a registry reference means “pull”.
PG_SHARED_BUFFERS / PG_WORK_MEM / PG_MAX_CONNECTIONSPostgreSQL tuning. Shared buffers about a quarter of PG_MEM_LIMIT.
Let the script pick the secrets
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.
Keep .env safe, and never commit it
It holds the database passwords and the token-signing secret. PostgreSQL keeps the superuser password it was first started with, so changing PG_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.

9 Build & start

# 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.

10 First-run setup wizard

  1. Open http://<server>:8080/ in a browser (localhost on the same machine).
  2. Because there is no database yet, the login screen shows Set up Ritzy. Click it.
  3. Enter the support password — the date-based DBA setup code.
  4. Complete the wizard: Company details → Financial Year → Administrator → Summary. It adds the first company to the database — financial year, numbering and reference data.
  5. Sign in with the administrator account you just created.
Adding more companies later
Sign in as that administrator and use DBA Console → Add Company. The same pipeline runs underneath.
This creates an empty company
The wizard creates a new, empty company. To bring an existing customer over from a Windows (Firebird) installation, use the migration scripts in §15 instead — before anyone runs the wizard.

11 Verify

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.

12 Ports & firewall

PortServiceNotes
8080Docker app (API + web UI)APP_PORT. The only port this bundle publishes.
5432PostgreSQLNot published — reachable only from the app container.
80Web dashboard and phone apps, native installnginx, non-Docker installs. The phone apps are under paths (/track/ …), with no ports of their own.
8080 is the Docker app's
A Docker stack and a native install can share one host: the native install uses port 80 (the phone apps are under paths on it) and leaves 8080 to the Docker app. A native install from before the phone apps moved to paths may still have nginx listening on 8080–8095; re-run its install.ps1 before starting the container.

13 Update, backup & rollback

Update

docker compose pull && docker compose up -d
# building locally instead:
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build

Rollback

Set RITZY_IMAGE in .env back to the previous version tag, then docker compose up -d.

Stop and start

docker compose down          # stop; data persists in the volume
docker compose up -d         # start again
down -v destroys every company
The -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.

Backups — backup-stacks.sh

Use pg_dump, never a volume snapshot
A file or volume snapshot of a running PostgreSQL is not a guaranteed-consistent backup. Always use pg_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
OptionEffect
--rclone remote:path / --rsync destCopy 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 30Days kept locally (default 14).
--dest /pathLocal backup folder (default ~/ritzy-backups).
--stack ~/ritzy-acmeOnly 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.

The backups hold secrets
Each backup includes the stack's database password and any ZATCA private key — a restore is useless without them. Restrict access to the backup destination.

Restore

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
ZATCA companies: restore with care
Restoring an older copy over a live ZATCA company rolls back its invoice counter and previous-invoice hash, and ZATCA then rejects the next invoices. Restore a ZATCA company only to recover from a real loss.

14 Several stacks on one host

First decide: another company, or another stack?

These are different things, and the wrong choice is expensive to undo.

Another company in one stackAnother stack
HowDBA Console → Add CompanyA second copy of the bundle, its own .env
DatabasesOne database, companies kept apart by Row-Level SecuritySeparate volumes, separate PostgreSQL
Users & loginsChosen from a company list at loginCompletely separate; no shared users
ResourcesOne PostgreSQL, one Node processEverything doubles
UpgradesAll companies move togetherIndependent — upgrade one, hold the other
Use it forBranches, divisions, group entitiesSeparate customers, or prod vs a test copy
Default to more companies, not more stacks
Multiple companies in one install is the normal path and the cheaper one. Reach for a second stack when you need real isolation — a different customer's data, or an upgrade you want to test without touching production.

The quick way — new-stack.sh

One 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.

By hand: what makes two stacks separate

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_NAMEacmeglobex
APP_PORT80808090
PG_PASSWORD / PG_APP_PASSWORD / JWT_SECRETits own setits own set
RITZY_IMAGEthe 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.

Every command is relative to the folder you are standing in
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.

Build the image once, run it in every stack

A copied stack folder cannot build — it can only run
The Dockerfile's build context is the repository root, one level above 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.

Sizing — this is what actually limits you

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:

SizeForPostgreSQL shared buffers / ceilingApp ceilingTotal
small1 company, trials192 MB / 768 MB, 1 CPU640 MB, 1 CPU~1.4 GB
standard (default)2–3 companies384 MB / 1.5 GB, 2 CPU1 GB, 2 CPU~2.5 GB
large3–5 companies1 GB / 4 GB, 4 CPU1.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.

Serving them by name instead of by port

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.

Backups with several stacks

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.

15 Moving a Windows customer in

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.

  1. On the customer's Windows database server, as Administrator. It needs Firebird's 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.
  2. Copy the zip to the Docker host over a trusted channel (scp/sftp). It holds every company's data and private keys.
  3. On the Docker host:
    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.

16 Many servers — build once, pull everywhere

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.

17 Troubleshooting

SymptomCause & 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).
More detail
See 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.