Files
rezervovator/deploy

Running Rezervovator for many customers

The application serves one company per instance. To host many customers, every customer gets their own container from the same image, with their own database, configuration and data volume. This directory contains what is needed to run that on one Docker host:

  • rezervator.sh — creates, imports, upgrades, backs up, restores and removes customers
  • docker-compose.yml — the optional shared services: a reverse proxy (Traefik) and a PostgreSQL server
  • config.template.toml — template of a customer's configuration
  • .env.example — settings of the stack

The reverse proxy and PostgreSQL can each be either a container of this stack or the one already installed on the host:

.env Value Meaning
REZERVATOR_PROXY traefik (default) Traefik container, routes by domain, issues Let's Encrypt certificates itself
nginx nginx installed on the host; the script generates a server block per customer
REZERVATOR_POSTGRES container (default) PostgreSQL container of this stack
host PostgreSQL installed on the host

The two settings are independent. Setup with nginx and PostgreSQL on the host below describes the combination for a server that already runs both.

Requirements

  • Docker 20.10 or newer, bash and openssl on the host. The Compose plugin is needed only when Traefik or PostgreSQL run as containers of this stack.
  • The application image on the host: docker build -t rezervator:latest . in the repository root, or pulled from a registry (see Distributing the image). The image must match the CPU architecture of the host.
  • A DNS record for every customer domain pointing to the host

First setup

./rezervator.sh init          # creates .env and stops
$EDITOR .env                  # proxy, PostgreSQL, image
$EDITOR config.template.toml  # the [mailing] section: your SMTP server
./rezervator.sh init          # starts the shared services, if any, and checks the setup

With REZERVATOR_PROXY=traefik, REZERVATOR_TLS=true gets a Let's Encrypt certificate for every customer domain and redirects HTTP to HTTPS; it needs ACME_EMAIL and ports 80 and 443 reachable from the internet. Use false only for local testing or behind another proxy that terminates TLS.

The session cookie is marked Secure. Whatever the setup, customers must reach the application over HTTPS, otherwise logging in works only on localhost addresses.

Distributing the image

Building the image takes several minutes and needs the Rust toolchain image. To set up another server quickly, build once, push the image to a container registry and pull it elsewhere. Gitea has a registry built in; the examples use the one at gitea.itsolved.cz and the organization itsolved.

On the server where the image was built:

docker login gitea.itsolved.cz
docker tag rezervator:latest gitea.itsolved.cz/itsolved/rezervator:1.2.0
docker push gitea.itsolved.cz/itsolved/rezervator:1.2.0

Log in with a Gitea user name and an access token (Settings → Applications) that may write packages. Use the version from Cargo.toml as the tag rather than only latest: it shows what runs where, and an older version stays available.

On another server:

docker login gitea.itsolved.cz      # only when the package is not public; a token that may read packages is enough
docker pull gitea.itsolved.cz/itsolved/rezervator:1.2.0

and in .env:

REZERVATOR_IMAGE=gitea.itsolved.cz/itsolved/rezervator:1.2.0

To release a new version, build and push it under a new tag, then on every server pull it, change the tag in .env and run ./rezervator.sh upgrade --all.

Things to know:

  • The image runs only on the CPU architecture it was built on (x86_64 when built on such a server).
  • The package belongs to the organization and follows its visibility: the image of a public organization can be pulled without logging in. The image contains no configuration or passwords.
  • If the push fails with 413 Request Entity Too Large, the reverse proxy in front of Gitea limits the upload size; raise client_max_body_size in its nginx server block.

Without a registry, an image can be copied to one server directly:

docker save rezervator:latest | gzip | ssh user@other-server 'gunzip | docker load'

Setup with nginx and PostgreSQL on the host

# .env
REZERVATOR_PROXY=nginx
REZERVATOR_POSTGRES=host
PG_ADMIN="sudo -u postgres"

Nothing from docker-compose.yml runs in this setup; init only creates the Docker network and checks that it can manage PostgreSQL and that containers can reach it.

Run the script as a user who may use Docker and sudo -u postgres.

PostgreSQL

The script manages databases with sudo -u postgres psql and pg_dump. The customer containers connect over the Docker network to host.docker.internal:5432, which is the host's address on the Docker bridge, so PostgreSQL has to accept connections from there. A default installation listens on localhost only.

  1. postgresql.conf: listen on the Docker bridge too. listen_addresses = '*' is the simple way; the next two steps keep it closed to everybody else. Check max_connections (see Sizing).

  2. pg_hba.conf: allow the Docker networks with password authentication. Every customer has a database and a role of the same name, so sameuser limits each role to its own database:

    host  sameuser  all  172.16.0.0/12  scram-sha-256
    
  3. Firewall: allow port 5432 from 172.16.0.0/12 and keep it closed from outside.

  4. Restart PostgreSQL and run ./rezervator.sh init. It fails with a message when the containers cannot reach the database.

172.16.0.0/12 covers Docker's default address pools; adjust it if your Docker uses other ranges. These steps were not tried on a Linux server yet — the script was tested against a PostgreSQL outside the stack reached through the host address, on Docker Desktop.

nginx

Every customer container listens on 127.0.0.1 on its own port (BASE_PORT and up), not reachable from outside. add writes customers/<name>/nginx.conf, a plain HTTP server block that proxies the domain to that port; the commands to install it and to add a certificate with certbot are at the top of the file. Adapt it to how TLS is done on your server.

nginx's default limit for uploads is 1 MB; the generated block raises it to 20 MB for banner images.

Moving an existing installation into a container

import creates a customer from an installation that runs directly on the host: it copies its database into a new one and the uploaded files from its data directory into a new volume. The accounts and passwords come along. The source is only read.

./rezervator.sh import main rezervace.example.com rezervator /srv/rezervator/target/site/data

The arguments are the new customer name, its domain, the name of the existing database and the directory with the uploaded banner (in old versions target/site/data next to the binary; leave it out if there is no banner). Config files in that directory are not copied.

Suggested order:

  1. Copy the [mailing] section of the old config.toml into config.template.toml.
  2. Try it: run import, check the new instance on its local port (./rezervator.sh list shows it), then ./rezervator.sh remove main --purge.
  3. For real: stop the old installation, run import again, and point the existing nginx server block of the domain to the new port (proxy_pass http://127.0.0.1:<port>;) — keeping its TLS settings is easier than installing the generated file. Reload nginx.
  4. Keep the old database until the new instance has proven itself, then drop it.

Bookings made in the old installation between the import and the switch are not in the new one, hence stopping it first.

The new instance gets a new session key and a new database password, so everybody has to log in again. If the old installation kept its config.toml in target/site/data, that file was publicly downloadable: change the SMTP password too. import warns when the account admin still has the password admin.

Adding a customer

./rezervator.sh add alpha rezervace.alpha.example

This creates the database and its role, writes customers/alpha/config.toml with a unique session key and database password, creates the data volume, starts the container and waits until it is healthy. At the end it prints the generated password of the admin account — it is shown only once.

An optional third argument sets the sender address of e-mails (default noreply@<domain>).

./rezervator.sh list shows all customers, their ports and the state of their containers.

Upgrading

Build or pull the new image under the name set in REZERVATOR_IMAGE, then:

./rezervator.sh upgrade alpha     # try one customer first
./rezervator.sh upgrade --all

Customers are upgraded one by one. For each one the script makes a backup, replaces the container (database migrations run when the new version starts) and waits for it to become healthy. Customers that already run the image are skipped, so an interrupted upgrade can simply be run again.

If a customer does not become healthy, the script puts back the database and data from the backup it just made, starts the previous image again and stops. The remaining customers are not touched. A customer is unavailable for a few seconds while its container is replaced.

Backup and restore

./rezervator.sh backup --all
./rezervator.sh restore alpha 20260101T020000Z

A backup is two files in backups/<name>/: <timestamp>-db.sql.gz (database dump) and <timestamp>-data.tar.gz (uploaded banner). restore replaces both the database and the data volume, and keeps the image the customer was running.

The script does not schedule backups, does not delete old ones and does not copy them off the host. Run backup --all from cron, and ship backups/, customers/ and .env somewhere else — customers/ and .env hold the passwords and session keys and cannot be regenerated.

Removing a customer

./rezervator.sh remove alpha            # stops the container, keeps everything else
./rezervator.sh remove alpha --purge    # also deletes the database, data volume and config

After a plain remove, upgrade alpha starts the customer again. --purge asks for the customer name as confirmation and keeps existing backups. With nginx, remove the customer's server block yourself.

Health and monitoring

Every instance answers GET /health with 200 when it can reach its database and with 503 otherwise. The image uses it as its Docker health check; list shows the result, and Traefik sends traffic only to healthy containers. Point an external monitor to https://<domain>/health.

Sizing

  • One idle instance used about 7 MiB of memory in a test, about 12 MiB after some use. PostgreSQL needs far more than that.
  • Every instance may open up to CUSTOMER_MAX_CONNECTIONS database connections (5 by default; written into the customer's config when the customer is created) and closes idle ones. max_connections of PostgreSQL (100 in a default installation, PG_MAX_CONNECTIONS for the container) must stay above the number of customers times that limit, plus what else uses the server. Beyond that, put PgBouncer in front of PostgreSQL.

Security notes

  • customers/ and .env contain secrets and are readable only by the user who ran the script. Keep them out of version control (they are in .gitignore).
  • Customers share one PostgreSQL server but each has its own database and role, without access to the others.
  • With Traefik: the proxy has read access to the Docker socket to discover containers, which is equivalent to root on the host. Do not expose its API or dashboard.
  • With PostgreSQL on the host: the script runs psql as the PostgreSQL superuser, so whoever may run it controls all databases on that server, not only the customers'.

Running a second stack

Change REZERVATOR_PREFIX (and the ports) in .env of another copy of this directory. All container, network and volume names are derived from the prefix.