10 KiB
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 customersdocker-compose.yml— the optional shared services: a reverse proxy (Traefik) and a PostgreSQL serverconfig.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,
bashandopensslon 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. 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.
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.
-
postgresql.conf: listen on the Docker bridge too.listen_addresses = '*'is the simple way; the next two steps keep it closed to everybody else. Checkmax_connections(see Sizing). -
pg_hba.conf: allow the Docker networks with password authentication. Every customer has a database and a role of the same name, sosameuserlimits each role to its own database:host sameuser all 172.16.0.0/12 scram-sha-256 -
Firewall: allow port 5432 from
172.16.0.0/12and keep it closed from outside. -
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:
- Copy the
[mailing]section of the oldconfig.tomlintoconfig.template.toml. - Try it: run
import, check the new instance on its local port (./rezervator.sh listshows it), then./rezervator.sh remove main --purge. - For real: stop the old installation, run
importagain, 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. - 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 and generated styles). 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_CONNECTIONSdatabase connections (5 by default; written into the customer's config when the customer is created) and closes idle ones.max_connectionsof PostgreSQL (100 in a default installation,PG_MAX_CONNECTIONSfor 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.envcontain 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
psqlas 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.