Configuration#

How settings are layered#

Three layers, lowest precedence first:

  1. Built-in defaults — DEFAULTS in app/config.py. The app runs with no config file at all.
  2. app/config/config.yml — your settings. If it does not exist, config.example.yml is used instead, so a fresh checkout boots.
  3. Environment variables (RT_*) — these win, and they cover the things that differ per deployment: ports, secrets, the database URL.

YAML layers are deep-merged, so you only need to write the keys you want to change. Setting targets.default_goal does not wipe targets.splits.

The merged result is available to the app as app.config["RT"] and to templates as settings.

cp app/config/config.example.yml app/config/config.yml
$EDITOR app/config/config.yml
docker compose restart web

Config is read once, at startup

Unlike the documentation tree, the config file is parsed when the process starts and cached. Restart the container after editing it.

Environment variables#

Set these in .env, which Compose passes into the container.

Variable Default What it does
RT_HTTP_PORT 8080 Host port published by Compose. The container always listens on 8000
RT_SECRET_KEY dev-only-insecure-key Flask session signing key. Change it. The session cookie is what remembers your active profile
RT_ENV production Free-form environment label, exposed as app.config["RT_ENV"]
RT_DATABASE_URL sqlite:////data/rubiks-trainer.db SQLAlchemy URL. Any SQLAlchemy-supported database works; SQLite and Postgres are what we test
RT_CONFIG_FILE app/config/config.yml Absolute path to the YAML config inside the container. Compose sets it to /app/app/config/config.yml
RT_ANALYTICS_ENABLED false Server-side traffic analysis to Matomo — see analytics
RT_MATOMO_URL — Base URL of the Matomo install, e.g. https://stats.example.org
RT_MATOMO_SITE_ID — The site's id in Matomo
RT_MATOMO_TOKEN — Optional auth token, so Matomo accepts the visitor's (truncated) address. A secret: never in config.yml
RT_MATOMO_REQUEST_ID_DIMENSION — Optional: number of a Matomo custom dimension to receive the request id

Note the four slashes in the SQLite URL: sqlite:////data/... is an absolute path, sqlite:///data/... is relative to the working directory. Getting this wrong is the most common cause of "my data disappeared" — see Troubleshooting.

config.yml#

app#

Key Default What it does
app.name Rubik's Trainer Shown in the header, the page titles and the footer
app.tagline CFOP practice for sub-20 solvers Sub-line under the brand and the HTML meta description
app.max_content_width_vw 90 Percentage of the viewport the main container may use. Matches --content-max in base.css

scrambler#

Controls full scramble generation. See Scrambling.

Key Default What it does
scrambler.base_twists 18 Base number of random face turns
scrambler.extra_twists_max 6 Up to this many further turns are appended, so length is 18–24
scrambler.forbid_same_face_repeat true Never emit two moves on the same face in a row (R R' would cancel)
scrambler.forbid_parallel_sandwich true Never emit A B A on parallel faces (R L R reduces to two moves)

Turning either rule off produces scrambles that are shorter than they look. Do not, unless you are testing the parser.

drills#

See Drills.

Key Default What it does
drills.weakest_case_count 3 How many of the weakest cases go into a generated session
drills.default_reps 5 Repetitions per case in a session
drills.inspection_seconds 0 WCA-style inspection countdown before each timed rep. 0 disables it

Raising weakest_case_count above about 6 defeats the point of the feature — the value of a small set is that you meet the same case again while the last rep is still fresh.

targets#

The heart of the scoring model. See Expected times for where these numbers come from and how much to trust them.

Key Default What it does
targets.splits five goals, see below Per-goal phase target times in seconds
targets.default_goal sub_20 Goal assigned to a new profile
targets.min_attempts_for_measured 3 Timed attempts needed before measured times replace the self-assessment in a case's score

Each entry under targets.splits is keyed by the goal name used on profiles (Profile.goal_key) and holds a label plus one float per phase:

targets:
  splits:
    sub_20:
      label: "Sub-20"
      cross: 2.5
      f2l: 9.5
      oll: 4.0
      pll: 4.0
Goal Label Cross F2L OLL PLL
sub_30 Sub-30 4.0 15.0 5.5 5.5
sub_20 Sub-20 2.5 9.5 4.0 4.0
sub_15 Sub-15 2.0 7.0 3.0 3.0
sub_12 Sub-12 1.5 5.5 2.5 2.5
sub_10 Sub-10 1.2 4.6 2.2 2.0

You can add your own goal — a sub_8 row, or a casual row with generous targets — and it appears in the profile goal picker automatically. Every phase key in PHASES (cross, f2l, oll, pll) should be present; a missing phase falls back to the default goal's value.

Lowering min_attempts_for_measured to 1 makes scores react immediately and also makes them jumpy, since a single lucky rep then defines a case. Three is the smallest number that lets the trimmed mean do anything useful.

assessment#

Key Default What it does
assessment.scale six labels, 0–5 The self-assessment scale shown in the wizard
assessment:
  scale:
    0: "Never seen it"
    1: "Recognise it, cannot solve it"
    2: "Solve it slowly, need to think"
    3: "Solve it, some hesitation"
    4: "Solid, smooth"
    5: "Instant, no thought"

You can reword the labels — for a younger student, or in another language — but do not change the range. 0..5 is baked into the score formula (self_score / 5 × 100, capped at 80) and into the Assessment.score column. Keys arriving from YAML as strings are normalised to integers on load.

viewer#

See The 3D viewer.

Key Default What it does
viewer.default_speed 2.0 Playback speed in moves per second
viewer.speed_min 0.25 Slowest the speed control goes
viewer.speed_max 8.0 Fastest the speed control goes
viewer.colors standard scheme Facelet colours, keyed by face letter, plus core
viewer:
  colors:
    U: "#f7f7f7"   # white
    D: "#ffd500"   # yellow
    F: "#009b48"   # green
    B: "#0045ad"   # blue
    R: "#b71234"   # red
    L: "#ff5800"   # orange
    core: "#1b1d21"

This is the standard western (BOY) scheme. Changing it affects the 3D viewer, the flat net and every case thumbnail, because all three read the same map. A common change is swapping F/B and R/L for the Japanese scheme.

The keys are face letters, not colour names, because the internal cube state is colour-agnostic — a facelet stores which face it belongs to, and colour is applied at render time.

docs#

Key Default What it does
docs.root docs Directory rendered at /docs. Relative paths resolve from the repository root

Point this somewhere else and /docs serves that tree instead. Only *.md files are served, and only from inside the root — anything resolving outside it (including through a symlink) returns 404.

analytics#

Traffic analysis with Matomo, done from the server: the app sends page views and events to Matomo's HTTP Tracking API itself. There is no Matomo JavaScript in the pages, so nothing to add to a Content-Security-Policy and nothing for an ad blocker to drop.

Key Default What it does
analytics.enabled false Master switch (RT_ANALYTICS_ENABLED). Off also needs nothing else to be unset
analytics.matomo_url "" Base URL; hits go to <matomo_url>/matomo.php (RT_MATOMO_URL)
analytics.site_id "" Matomo site id (RT_MATOMO_SITE_ID)
analytics.request_id_dimension unset Custom dimension number for the request id (RT_MATOMO_REQUEST_ID_DIMENSION)
analytics.timeout 2 Seconds the background sender waits for Matomo
analytics.ignore_paths static, /healthz, robots.txt, sitemap.xml, favicon.ico, llms.txt Path prefixes that are neither page views nor access-log lines
analytics.bot_agents bot, crawler, spider, curl/, … User-agent substrings (case-insensitive) treated as non-human

Nothing is sent unless enabled is on and a URL and site id are set.

What is tracked

  • A page view for every successful HTML page, named after its route — library.index, library.case / OLL 27 — Sune, docs.page / user/library. Never a database id or a query string. The live filter's partial refreshes and JSON calls are not page views.
  • Events: auth (sign-in, first-sign-in); practice (rate with the score, drill-start, drill-finish with the reps done, solve with the time in ms); library (case-open, display-switch).
  • Site search from the library's search box, once typing has settled, with the result count and the phase as category.

The last two — the display switch and the search — happen only in the browser. A small first-party script reports them to the app's own /analytics/event, which checks them against an allow-list and forwards them. The script is only on pages that are tracked.

What is not

  • Anything for a browser sending DNT: 1 or Sec-GPC: 1, for a crawler, or for an ignored path. Those visitors don't even get the script.
  • A whole IP address. Without RT_MATOMO_TOKEN no address is sent at all, and Matomo records the server's own address for every hit — visitor and location reports are then meaningless. With the token, the address goes out truncated (IPv4 /24, IPv6 /48).
  • Who someone is. A signed-in user is sent as uid, an HMAC of their account id keyed with RT_SECRET_KEY — stable across visits, not reversible to the account. Rotating the secret starts everyone over as new users.
  • Cookies: the app sets no tracking cookie. Signed-out visitors are grouped by Matomo's own heuristics (truncated address and browser).

Sending happens on a background thread per worker, from a queue of at most 1000 hits. If Matomo is slow or down, hits are dropped — never waited for — and a warning is logged at most once a minute.

On the Matomo side

  1. Create a site for the trainer and note its id.
  2. For RT_MATOMO_TOKEN, create a token for a user with write access to that site (Personal → Security → Auth tokens). Matomo needs it to accept cip.
  3. Under Privacy → Anonymize data, turn on IP anonymisation (2 bytes) as well, for anything that reaches Matomo by another route, such as an imported nginx log.
  4. Under Privacy → Anonymize data → Regularly delete old raw data, delete raw visit logs older than 180 days — the period the privacy notice promises. Aggregated reports can stay.
  5. Optionally create a visit-scope custom dimension, "Request id", and set its number as RT_MATOMO_REQUEST_ID_DIMENSION.

A minimal config.yml#

You do not need the whole example file. This is a complete, valid config:

app:
  name: "Cube Club"

targets:
  default_goal: sub_15

drills:
  weakest_case_count: 5
  inspection_seconds: 15

Everything else falls through to the defaults.

Checking what is loaded#

docker compose run --rm web python -c \
  "from app.config import load_settings; import json; print(json.dumps(load_settings(), indent=2))"

That prints the fully merged settings, which is the fastest way to find out whether your file is being read at all. If it looks like the defaults, check RT_CONFIG_FILE and that the file is actually mounted into the container.

Source: docs/admin/configuration.md