Skip to content

Docker Deployment

Docker Compose stands up the whole stack (PostgreSQL with PostGIS, Redis, the Flask app, two Celery workers, and a Caddy edge with automatic HTTPS) from one command. It is the quickest way to evaluate Bayanat and the cleanest to remove afterwards.

Not the recommended path for production

Use the native installation for any deployment you intend to keep. bayanat install yourdomain.com is a single command and it is the supported and tested deployment path. It is also the only path with update tooling: bayanat update verifies a signed release, snapshots the database, health-checks the result and reverts if that check fails. bayanat snapshots, bayanat restore and bayanat status go with it.

Under Docker there is no bayanat update. Every upgrade and every rollback is manual, and your only rollback point is the dump you take beforehand.

Requirements

  • A Linux host with Docker Engine and the Compose v2 plugin (docker compose, not the legacy docker-compose binary)
  • 8 GB RAM minimum, 4 CPU cores, and disk sized for your media
  • A domain name with an A record pointing at the host, if you want HTTPS
  • Ports 80 and 443 reachable from the internet, so Let's Encrypt can validate the domain

Install

bash
git clone https://github.com/sjacorg/bayanat.git
cd bayanat

# Generates .env with fresh secrets and prompts for your domain
./gen-env.sh -d

docker compose up -d

gen-env.sh -d writes a .env containing a new SECRET_KEY, password salt, TOTP secret, and random PostgreSQL and Redis passwords. It also asks for the domain Bayanat will be served on. Supply one and Caddy requests a Let's Encrypt certificate on first boot and the app is configured for HTTPS (SECURE_COOKIES and FORCE_HTTPS are set to True). Leave it blank and the stack serves plain HTTP on port 80, which is appropriate for local evaluation or when you already run a TLS-terminating proxy in front of it.

You can skip the prompt with ./gen-env.sh -d -D bayanat.example.org.

WARNING

.env holds every secret the deployment has. It is excluded from git. Back it up somewhere safe: without it, an existing database is unreadable, because password hashes and two-factor secrets are keyed to SECURITY_PASSWORD_SALT and SECURITY_TOTP_SECRETS.

The database and Redis passwords are passed to their containers as environment variables, so anyone who can run docker inspect on the host can read them. Membership of the docker group is equivalent to root on the host, so treat it as such and keep it to the operators who administer this deployment.

First Sign-in

On a fresh database the app container creates the schema and an admin user, then prints a one-time random password to its logs:

bash
docker compose logs bayanat | grep "Generated password"

Sign in at your domain with admin and that password. The setup wizard runs after first login. Change the password from your account settings afterwards.

Record the password before you recreate the container. It is printed once, to that container's log, and docker compose up -d --force-recreate replaces the container and discards it.

If the admin account was deleted, recreate it:

bash
docker compose exec bayanat flask install -u admin

That command refuses to act when an admin already exists. To set a new password for an existing account, reset it instead:

bash
docker compose exec bayanat flask reset -u admin

It prompts for the new password twice and enforces the password policy.

What Runs

ServicePurpose
caddyTLS termination, static files, reverse proxy. Ports 80 and 443
bayanatuWSGI application server
celeryDefault queue worker, plus the beat scheduler
celery-ocrDedicated OCR queue worker
postgresPostgreSQL 16 with PostGIS
redisCelery broker and session store

Every service restarts automatically unless you stop it deliberately, so the stack comes back after a host reboot. Container logs are capped at 10 MB per file with five files kept, so they cannot fill the disk.

Only caddy publishes ports. Postgres and Redis are reachable on the internal Compose network only.

Upgrading

Upgrading from v4 is a different procedure

This section covers routine upgrades between v5 releases. The v4 to v5 hop is not a routine upgrade: PostgreSQL moves from 15 to 16, and a PostgreSQL data directory is not compatible across major versions. Following the steps below from a v4 stack leaves PostgreSQL 16 starting against a version 15 data directory, and it will not come up.

Use the migration in Upgrading instead, which dumps the database, removes the old volume, and restores into a freshly initialized one.

The container entrypoint runs flask db upgrade on every start, so upgrading between v5 releases is: back up, pull, rebuild, restart.

Always take a database dump first. Migrations are not reversible.

bash
cd bayanat

# 1. Back up the database and your secrets. Note the filename; the rollback
#    steps below need this exact path.
docker compose exec -T postgres pg_dump -Fc -U bayanat bayanat \
  > ~/bayanat-$(date +%F).dump
cp .env ~/bayanat-env-$(date +%F).bak

# 2. If you may want to roll back, keep the images you are running. Compose
#    names built images after the project, so the workers rebuild onto the very
#    same tags and the current ones become unreachable. Give them names of
#    their own first; the rollback steps below tag them back.
docker image tag "$(docker compose config --images | grep -- '-celery$')" \
  bayanat-rollback-celery
docker image tag "$(docker compose config --images | grep -- '-celery-ocr$')" \
  bayanat-rollback-celery-ocr

# 3. Fetch the release you want. Pick a tag from the releases page;
#    the value below is an example.
git fetch --tags
git checkout v5.0.0

# 4. Rebuild the images
docker compose build

# 5. Restart. Migrations run automatically as the app container starts.
docker compose up -d

Watch the app come up and confirm the migration ran:

bash
docker compose logs -f bayanat

Then verify:

bash
docker compose exec bayanat flask doctor
docker compose exec bayanat flask db current

flask doctor checks the database, PostGIS, pg_trgm, pending migrations, schema alignment, Redis, Celery, the filesystem, and config. Every check should pass. Sign in and confirm the application behaves as expected.

TIP

docker compose up -d only recreates containers whose image or configuration changed. There is no need to down the stack first, and doing so takes the site offline for the whole rebuild rather than just the restart.

Rolling Back

If an upgrade goes wrong, return to the previous tag and restore the dump. The schema must match the code, so restoring the database alone is not enough.

The application and workers must be stopped for the restore, or the old code runs migrations against the database while it is being replaced.

Rolling back to v4 requires images you saved before upgrading

The v4 images can no longer be rebuilt. v4's stack builds its edge proxy from bitnami/nginx:1.24, and that tag has been removed from Docker Hub, so docker compose build on a v4 tag fails outright.

Not pruning is not enough on its own. Both stacks build the workers without an explicit image name, so Compose names them after the project and the v5 build replaces those tags in place. Checking out v4 afterwards and starting the stack would run v5 workers against a v4 database. Only the images you tagged separately before upgrading survive that, which is step 2 of both the routine upgrade above and Path C, the v4 to v5 migration.

If they are gone, rolling back to v4 on Docker is not possible and your route is a fresh native install restored from your dump.

Rolling back from v5 also moves PostgreSQL back from 16 to 15, and a PostgreSQL 16 data directory cannot be read by PostgreSQL 15. The old volume has to go and the pre-upgrade dump is restored into a freshly initialised PostgreSQL 15. Restoring before switching tags does not work.

bash
# 1. Stop the whole stack
docker compose down

# 2. Go back to the tag you were on before the upgrade, not the one you were
#    upgrading to
git checkout v4.0.2

# 3. Remove the PostgreSQL 16 volume. PostgreSQL 15 cannot start against it.
#    Your data comes back from the dump, so do not do this without one.
docker volume rm <project>_postgres_data

# 4. Bring up PostgreSQL 15 alone so it initialises an empty database. Do not
#    start the application yet: it would create its own schema and the restore
#    would collide with it. No build: v4 images cannot be rebuilt.
docker compose up -d postgres

# 5. Wait until it is actually accepting connections
until docker compose exec -T postgres pg_isready -q; do sleep 2; done

# 6. Restore the pre-upgrade dump into the empty database
BACKUP=~/bayanat-2026-08-16.dump
docker compose exec -T postgres pg_restore -U bayanat -d bayanat --no-owner < "$BACKUP"

# 7. Put the saved v4 worker images back on the tags Compose is about to start.
#    Without this the v5 workers built during the upgrade still hold those tags.
docker image tag bayanat-rollback-celery \
  "$(docker compose config --images | grep -- '-celery$')"
docker image tag bayanat-rollback-celery-ocr \
  "$(docker compose config --images | grep -- '-celery-ocr$')"

# 8. Start the rest of the stack
docker compose up -d

The dump must be the one taken before the upgrade. It carries the v4 schema, which is what the v4 code expects. A dump taken after upgrading carries the v5 schema and will not work with v4.

Unlike the native installer, the Docker path does not take automatic pre-upgrade snapshots. The dump you take before upgrading is your only rollback point, so do not skip it.

Backups

Bayanat can take scheduled database backups itself, locally or to S3. Set BACKUPS=1 and the related variables in .env; see Configuration. They are written to ./backups on the host.

Media files live on the host at the path in MEDIA_PATH (default ./enferno/media) and are not covered by database backups. Back them up separately.

What a full restore needs, beyond the database dump:

  • .env, or the database is unreadable
  • config.json
  • the media directory

Certificates in the caddy_data volume do not need backing up: Caddy reissues them on a new host. Do not delete that volume casually on a working host, though, because repeated reissuance runs into Let's Encrypt rate limits.

A manual dump at any time:

bash
docker compose exec -T postgres pg_dump -Fc -U bayanat bayanat > backup.dump

Operations

bash
# Status of every service, including health
docker compose ps

# Follow logs
docker compose logs -f bayanat
docker compose logs -f celery

# Restart after a config.json change
docker compose restart bayanat celery celery-ocr

# Flask CLI
docker compose exec bayanat flask doctor

# Database shell
docker compose exec postgres psql -U bayanat bayanat

Stopping and Removing

bash
# Stop the stack. Data is kept, `up -d` brings it back as it was.
docker compose down

To remove an evaluation completely, delete the volumes as well:

bash
docker compose down -v

DANGER

down -v destroys the database, the Redis data and the issued certificates. There is no undo. Take a dump first if there is anything in the deployment you want to keep.

Media is not stored in a volume. It stays on the host at MEDIA_PATH (default ./enferno/media) and must be deleted separately.

Configuration

Three sources, in the order Bayanat merges them:

  1. .env on the host, mounted read-only into the app and worker containers. Secrets and infrastructure settings.
  2. config.json on the host, mounted read-write. Feature toggles, mail, media and map settings, editable from the System Administration dashboard.
  3. Hardcoded defaults in enferno/settings.py.

Applying a change to .env

After editing .env, run:

bash
docker compose up -d --force-recreate bayanat celery celery-ocr

Plain docker compose up -d is not enough, and neither is restart. Both files are mounted individually rather than as a directory, and most editors (and sed -i) save by writing a new file and renaming it over the old one. The container stays attached to the original file, so it keeps reading the old settings, silently, until it is recreated. Editing in place with nano avoids this, but recreating is the reliable habit.

The same applies to config.json when you edit it on the host. Changes made through the System Administration dashboard are written by the application itself and only need a restart of bayanat and the workers.

Tuning knobs specific to this deployment, all optional in .env:

VariableDefaultPurpose
DOMAIN:80Caddy site address. A hostname enables automatic HTTPS
MEDIA_PATH./enferno/mediaHost path for uploaded media
UWSGI_PROCESSES4Application worker processes
UWSGI_THREADS2Threads per worker
UWSGI_HARAKIRI300Seconds before a stuck request is killed

UWSGI_HARAKIRI must outlast your slowest upload. Request bodies are streamed rather than buffered, so a worker is occupied for the whole duration of a media upload, and harakiri cannot tell a slow upload from a hung request. Five minutes covers a 1 GB file at roughly 30 Mbit/s. Raise it if your users upload large media over slower links.

Behind an Existing Proxy

If you already terminate TLS elsewhere, leave DOMAIN blank so Caddy serves HTTP on port 80, and point your proxy at it. In .env, set:

SECURE_COOKIES=True
FORCE_HTTPS=False

SECURE_COOKIES=True is correct because users still reach the site over HTTPS, so the session cookie must be marked secure.

DANGER

Do not set FORCE_HTTPS=True in this arrangement. It makes the app redirect any request whose X-Forwarded-Proto is not https, and Caddy sets that header from the connection it received, which is plain HTTP from your proxy. The result is an infinite redirect loop and a completely unreachable site.

Your outer proxy is responsible for redirecting HTTP to HTTPS and for sending the Strict-Transport-Security header, which is where that belongs when it owns the certificate.

Development and Testing

These are not production configurations.

bash
# Development stack, app exposed on 127.0.0.1:5000, no edge
docker compose -f docker-compose-dev.yml up

# Test suite
docker compose -f docker-compose-test.yml up

Troubleshooting

Caddy will not issue a certificate. Let's Encrypt must reach the host on port 80 to validate. Confirm the A record resolves to the host and that no firewall blocks 80 or 443, then check docker compose logs caddy.

bayanat restarts in a loop with PermissionError: '/app/logs/bayanat.log'. The containers run as uid 1000, and on Linux a bind mount keeps the host's ownership, so directories owned by root are not writable by the application. gen-env.sh -d sets this up for you; if you created the directories by hand, or copied the deployment from elsewhere, fix the ownership and restart:

bash
sudo chown -R 1000 logs backups enferno/imports enferno/media config.json
docker compose up -d

Docker Desktop on macOS remaps ownership and hides this, so a deployment that works on a developer laptop can still fail on a Linux server.

bayanat never becomes healthy. Its health check calls /health, which touches both PostgreSQL and Redis. Check docker compose logs bayanat for connection errors, and confirm POSTGRES_PASSWORD and REDIS_PASSWORD in .env match what the database and Redis containers were created with. If you changed them after first boot, the existing volumes still hold the old credentials.

Caddy does not start. It waits for bayanat to report healthy, so that users see no gateway errors during a restart. Fix the app first.

Uploads fail after a few minutes. harakiri killed the request. Raise UWSGI_HARAKIRI in .env and run docker compose up -d. The harakiri without post buffering warning in the app logs at startup is expected and explains the same trade-off.

Everything is slow on an ARM host. The PostGIS image is published for amd64 only and runs under emulation on Apple Silicon and ARM servers. This is fine for evaluation and unsuitable for production; deploy on x86_64.