This guide is specific to the Wikimedia Toolforge environment: it assumes the Toolforge
Kubernetes buildservice, the toolforge CLI, Toolforge tool accounts, and Toolforge-managed
NFS and databases. Every step and prerequisite below is Toolforge-specific — deploying Montage
on a generic or self-hosted server is out of scope here.
Register an OAuth 2.0 client on Meta-Wiki: choose OAuth 2.0, set the callback (redirect) URI to https://<tool>.toolforge.org/complete_login (e.g. https://montage-beta.toolforge.org/complete_login), and grant the basic scope. Save the client ID and client secret — you will need them in step 3. Each tool account needs its own client, because the callback URI differs per environment.
ssh <shell-username>@login.toolforge.org
become montage-betaReplace montage-beta with montage or montage-dev as appropriate.
Toolforge stores these as Kubernetes Secrets, injected into every pod on start. Use the
interactive prompt for secret values — never pass them as command-line arguments (they
would appear in ~/.bash_history on the shared bastion).
toolforge envvars create MONTAGE_ENV # enter: devlabs / beta / prod
toolforge envvars create MONTAGE_OAUTH_CLIENT_ID # OAuth 2.0 client ID from Special:OAuthConsumerRegistration
toolforge envvars create MONTAGE_OAUTH_CLIENT_SECRET # OAuth 2.0 client secret
toolforge envvars create MONTAGE_OAUTH_REDIRECT_URI # e.g. https://montage-beta.toolforge.org/complete_login
toolforge envvars create MONTAGE_COOKIE_SECRET # generate with: openssl rand -hex 32
toolforge envvars create MONTAGE_DB_URL # mysql+pymysql://<user>:<pass>@tools.db.svc.wikimedia.cloud/<db>?charset=utf8mb4
toolforge envvars create MONTAGE_SUPERUSERS # OPTIONAL — enables su-impersonation only, NOT admin access (see "Maintainer vs superuser" below)
toolforge envvars create MONTAGE_API_LOG_PATH # e.g. /data/project/montage-beta/montage_api.log
toolforge envvars create MONTAGE_REPLAY_LOG_PATHOptional env vars (all have sensible defaults):
| Variable | Default | Description |
|---|---|---|
MONTAGE_DB_ECHO |
false |
Log all SQL queries |
MONTAGE_DEBUG |
false |
Enable debug mode |
MONTAGE_ROOT_PATH |
/ |
URL root path |
MONTAGE_LABS_DB |
true |
Enable Wikireplica queries |
MONTAGE_FEEL_LOG_PATH |
(none) | Path for feel log |
mariadb --defaults-file=~/replica.my.cnf -h tools.db.svc.wikimedia.cloudCREATE DATABASE `<user>__<db name>` DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
EXIT;The <user> prefix must match the username in ~/replica.my.cnf. See Toolforge user databases.
The MONTAGE_DB_URL you set in step 3 should use this database name.
toolforge build start https://github.com/hatnote/montage.git --ref <branch>This builds the Python app and Vue frontend together into a single container image. Monitor progress:
toolforge build logsWait until the build completes successfully before continuing.
Run inside the buildservice shell (uses the same image as the webservice):
toolforge webservice buildservice shell
export USER=montage # required — see note
launcher python tools/create_schema.py
exitWhy
export USER=montage: the buildservice shell does not run the app entrypoint, soUSERis unset and the container has no passwd entry.pymysqlcallsgetpass.getuser()at import time and raisesOSError: No username set in the environment, which aborts any script that importsmontage.rdb/labs. The running webservice avoids this becauseapp.pydoesos.environ.setdefault('USER', …)before importing pymysql — but that only runs via the Procfile (gunicorn app:app), not in an interactive shell. SetUSERmanually there.
If upgrading an existing deployment, run the migration SQL instead (on the bastion):
mariadb --defaults-file=~/replica.my.cnf -h tools.db.svc.wikimedia.cloud <db name> < tools/migrate_prod_db.sqltoolforge webservice buildservice start --mount all--mount all is required — Montage writes logs to NFS (/data/project/<toolname>/), so shared
storage must be mounted.
Confirm the service is running:
https://<tool>.toolforge.org/meta/— should show a recent start time.https://<tool>.toolforge.org/v1/health— should reportdb: ok,status: healthy(verifies database connectivity, not just that the app booted).
Maintainer (admin) access is hardcoded in code, not configured. The MAINTAINERS list in
montage/rdb.py is the source of truth for who is an admin (User.is_maintainer checks
membership in it). It is identical on every deployment (dev/beta/prod) and is not read from
config or env vars. To grant or revoke maintainer access you must edit that list in rdb.py and
redeploy — setting MONTAGE_SUPERUSERS does not do it. ensure_series() also indexes
MAINTAINERS[0], so the list must never be empty.
MONTAGE_SUPERUSERS is a separate, optional feature. It enables impersonation: a listed
user may pass su_to=<username> to act as another user (for support/debugging), gated on a valid
signed cookie (montage/mw/__init__.py). Legacy YAML config called this superuser (singular);
the env var is MONTAGE_SUPERUSERS (comma-separated). Leave it unset to disable impersonation
entirely — it has no effect on who is an admin.
Before a deploy that restarts or stops the service, confirm nobody is mid-vote.
The audit log records admin actions (round activate/finalize) but not juror voting: https://montage-beta.toolforge.org/v1/logs/audit
For juror voting, query the database directly (works on any deploy model). Get the <db name>
from config.<env>.yaml (db_url) or toolforge envvars, then:
mariadb --defaults-file=~/replica.my.cnf -h tools.db.svc.wikimedia.cloud <db name> -e "SELECT UTC_TIMESTAMP() AS now_utc, (SELECT COUNT(*) FROM votes WHERE modified_date >= UTC_TIMESTAMP() - INTERVAL 15 MINUTE) AS votes_15m, (SELECT MAX(modified_date) FROM votes) AS last_vote_utc, (SELECT COUNT(*) FROM rounds WHERE status='active') AS active_rounds\\G"votes.modified_date is stored in UTC and stamped when a juror casts or edits a vote. If
active_rounds is 0, nothing is open for voting; if votes_15m is 0 and last_vote_utc
is well in the past, it is safe to restart. A juror mid-round loses no data — each vote is
committed on submission — they simply resume once the service is back.
ssh <shell-username>@login.toolforge.org
become montage-betaClone the repo so the deploy script is available on the bastion:
git clone --branch tools/buildservice https://github.com/hatnote/montage.git ~/www/python/srcIf the repo is already cloned, make sure the branch tracks the right remote:
git -C ~/www/python/src fetch origin
git -C ~/www/python/src checkout -b tools/buildservice origin/tools/buildservicebash ~/www/python/src/tools/deploy.sh --ref <branch>The script will: pull the latest version of itself, start the build, wait for
completion, verify the SHA and port, warn if the running image already matches,
restart the service, and smoke-test /meta/.
If a tool is still running the old python3.11/python3.13 NFS webservice — check with
toolforge webservice status — the deploy script cannot be used yet: it issues a restart,
which only applies to an already-running buildservice. Do the one-time switch manually:
whoami # MUST be tools.<intended-tool> before any mutating command
# Ensure env vars are set (Fresh install step 3) and the image is built (Fresh install step 5),
# then swap the webservice type:
toolforge webservice stop
toolforge webservice buildservice start --mount all--mount all is required; starting without it fails with
ERROR: --mount not explicitly specified on a build service based tool.
The legacy webservice reads its config from a YAML file on NFS, whereas the buildservice reads it
from toolforge envvars. A tool that worked on the legacy service can therefore still fail on the
buildservice if the MONTAGE_* env vars (Fresh install step 3) are not set — and its database may
need migrating (see "Startup crash: missing column" below).
Before restarting, verify the build used the expected commit and port:
toolforge build logs 2>&1 | grep -E "RESULT_SHA|gunicorn"The [step-clone] line should show the expected RESULT_SHA=<sha> and the
[step-build] line should show gunicorn --bind=0.0.0.0:8000. If the SHA is wrong,
trigger a new build before restarting.
This means the pod never passed the Toolforge startup probe, which checks port 8000. Most common cause: the running image binds to the wrong port. Use the build log check above to confirm the built image uses port 8000, then restart.
If the pod logs (toolforge webservice buildservice logs) show:
!! Model <class 'montage.rdb.Entry'> missing column file_id from database ...
!! recreate the database and update the code, then try again
the database schema is behind the code (common when a tool has been off the buildservice for a while). Run the migration — do not "recreate the database", which would wipe existing data:
mariadb --defaults-file=~/replica.my.cnf -h tools.db.svc.wikimedia.cloud <db name> < tools/migrate_prod_db.sql
toolforge webservice buildservice restart --mount alltools/create_schema.py only creates missing tables (CREATE TABLE IF NOT EXISTS); it does
not ALTER existing tables, so it will not add a missing column. Use the migration SQL for
schema changes to an existing database.
Montage writes several log files to NFS (/data/project/<project>/). These are only
available because the service is started with --mount all; the files persist across restarts.
Legacy (pre-buildservice) request log: the old uWSGI webservice logs every request —
method, path, status, timing — to /data/project/<project>/uwsgi.log (e.g.
/data/project/montage/uwsgi.log). Health-check pings show up as frequent identical GET /
lines; ignore those and robots.txt when gauging real activity. Under the buildservice this is
replaced by montage_api.log (below) plus gunicorn stdout via toolforge webservice buildservice logs.
| Log file | Set via | What it captures |
|---|---|---|
montage_api.log |
MONTAGE_API_LOG_PATH |
Every API request — method, path, user, timing, result |
montage_api.exc.log |
(derived — same path with .exc suffix) |
Full tracebacks for 5xx errors |
montage_replay.log |
MONTAGE_REPLAY_LOG_PATH |
Raw request replay data (optional; used for debugging) |
montage_feel.log |
MONTAGE_FEEL_LOG_PATH |
Juror experience events (optional) |
Tail the API log (most useful starting point):
tail -50 /data/project/<project>/montage_api.logFor 5xx tracebacks:
tail -50 /data/project/<project>/montage_api.exc.logGunicorn / pod stdout (replaces the old uwsgi.log): gunicorn writes its own startup
messages and unhandled errors to stdout, which Kubernetes captures. To view it:
toolforge webservice buildservice logsThis is ephemeral — it reflects the current pod's output since the last restart and is not written to a file.
Use the buildservice shell — prefix Python with launcher. Set USER first (the shell skips
the app entrypoint that normally sets it — see the note under "Initialise the database schema"),
and import the top-level app module (which sets USER and builds the real WSGI app) rather than
montage.app directly:
toolforge webservice buildservice shell
export USER=montage
launcher python -c "import app; print('app build OK')" # exercises full startup: config, cookie guard, OAuth
launcher python tools/create_schema.py
exittoolforge webservice buildservice restart --mount allmariadb --defaults-file=~/replica.my.cnf -h tools.db.svc.wikimedia.cloud <db name>SELECT COUNT(*) FROM entries;
DESCRIBE entries;toolforge envvars create MONTAGE_SUPERUSERS # overwrites existing value
toolforge webservice buildservice restart --mount allEach tool account (montage-dev, montage-beta, montage) builds from its own branch and
has its own toolforge envvars configuration. The build command is the only thing that
differs:
| Account | Branch | URL |
|---|---|---|
montage-dev |
master (or feature branch for testing) |
https://montage-dev.toolforge.org |
montage-beta |
master |
https://montage-beta.toolforge.org |
montage |
release tag | https://montage.toolforge.org |
--forwarded-allow-ips=* in the Procfile: this tells Gunicorn to trust
X-Forwarded-For headers from any IP. This is safe on Toolforge because pods are not
directly reachable from the internet — traffic arrives exclusively through the
HAProxy → nginx-ingress chain. Do not copy the Procfile verbatim to other deployment
environments without understanding this dependency.
Secrets are never in git or config files: all credentials are stored as toolforge envvars
(Kubernetes Secrets). The config YAML files in the repo contain only non-secret defaults and
should never hold real credentials.