Installation#

The app ships as a Docker Compose project. There is no other supported way to run it, and there is no host Python environment to manage.

Requirements#

Minimum Notes
Docker Engine 24 Or Docker Desktop 4.30+
Docker Compose v2 The docker compose subcommand, not the old docker-compose script
Disk ~600 MB Mostly the python:3.12-slim base image and the wheels
RAM 512 MB The container idles well under that
Browser Any current one The 3D viewer needs WebGL; without it you get the flat net

No network access is needed at runtime. The build downloads Python packages, so the first docker compose build needs to reach PyPI.

Install#

git clone <your-clone-url> rubiks-trainer
cd rubiks-trainer

cp .env.example .env
cp app/config/config.example.yml app/config/config.yml

docker compose up -d --build

Then open http://localhost:8080/.

Before you start it: two things worth editing#

.env — at minimum change the secret key:

RT_HTTP_PORT=8080
RT_SECRET_KEY=$(openssl rand -hex 32)     # put the actual value in the file
RT_DATABASE_URL=sqlite:////data/rubiks-trainer.db

The secret key signs the session cookie, which is what remembers your active profile. Leaving the default is not a disaster on a laptop, but change it.

app/config/config.yml — the defaults are sensible. The one key worth a look before you start is targets.default_goal, which decides what a new profile is measured against. See Configuration.

What happens on first run#

  1. Flask creates the database schema (db.create_all()).
  2. The algorithm library is imported from app/data/algorithms.json — 41 F2L, 57 OLL and 21 PLL cases with their groups and algorithms.
  3. The built-in training plans are inserted.
  4. A default profile called Me is created, with the goal from targets.default_goal.

This takes a few seconds. Watch it happen:

docker compose logs -f web

Then confirm the app is healthy:

curl -s http://localhost:8080/healthz
# {"status":"ok","version":"1.0.0"}

If the library did not import, see Troubleshooting.

Where things live#

Path Persisted?
Database /data/rubiks-trainer.db in the rt-data volume Yes — survives docker compose down
Config app/config/config.yml, bind-mounted Yes, it is a file in your checkout
Docs docs/, bind-mounted at /app/docs Yes
Application code app/, bind-mounted at /app/app Yes
PDFs pdf/, bind-mounted read-only Yes

Because app/, docs/, tests/ and scripts/ are bind-mounted, editing them on the host changes what the container sees immediately. You only need to rebuild the image when requirements.txt or the Dockerfile changes.

Everyday commands#

docker compose up -d                    # start
docker compose logs -f web              # follow logs
docker compose ps                       # status, including health
docker compose restart web              # after editing config.yml
docker compose down                     # stop (the volume survives)

docker compose run --rm web pytest      # the Python test suite
docker compose run --rm jstest          # the browser-side JS suite
docker compose run --rm web flask seed  # re-import the algorithm library

Never run Python on the host

Not pytest, not pip, not flask. The container is the only environment with the right dependency versions, and a host virtualenv writing into instance/ or app/data/ is a reliable way to produce bugs nobody else can reproduce. Every command in this documentation goes through docker compose.

Changing the port#

The container always listens on 8000. RT_HTTP_PORT chooses the host port:

# .env
RT_HTTP_PORT=9000
docker compose up -d

Using Postgres instead of SQLite#

SQLite is the default and is entirely adequate for a handful of profiles. If you would rather use Postgres, point the URL at it:

# .env
RT_DATABASE_URL=postgresql+psycopg://rt:secret@db:5432/rubiks

You will need to add a db service to docker-compose.yml and the psycopg driver to requirements.txt, then rebuild. The schema is created on first boot exactly as with SQLite.

Running behind a reverse proxy#

The app has no authentication (see Administration), so if it is reachable from anywhere other than your own machine, the proxy has to provide it. A minimal nginx front end:

server {
    listen 443 ssl;
    server_name cube.example.org;

    ssl_certificate     /etc/letsencrypt/live/cube.example.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cube.example.org/privkey.pem;

    auth_basic           "Rubik's Trainer";
    auth_basic_user_file /etc/nginx/rt.htpasswd;

    location / {
        proxy_pass         http://127.0.0.1:8080;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
    }
}

Also bind the published port to localhost only, so the app is not reachable around the proxy:

ports:
  - "127.0.0.1:${RT_HTTP_PORT:-8080}:8000"

The app sets X-Content-Type-Options, X-Frame-Options and Referrer-Policy on every response; TLS and HSTS are the proxy's job.

Client addresses in the log#

Every request now arrives from the proxy, so unless the app is told to read X-Forwarded-For the access log records the proxy's address for all of them — and since the proxy is the only thing that saw the TLS, absolute links come out http:// on an https:// site.

The app reads both headers by default. What it trusts is set per header, as a number of proxies:

Variable Default Header
RT_PROXY_X_FOR 1 X-Forwarded-For — the client's address
RT_PROXY_X_PROTO 1 X-Forwarded-Protohttps when TLS ends at the proxy
RT_PROXY_X_HOST 0 X-Forwarded-Host
RT_PROXY_X_PORT 0 X-Forwarded-Port
RT_PROXY_X_PREFIX 0 X-Forwarded-Prefix — for mounting under a sub-path

Count the proxies between the client and the app: the nginx above is 1, the same nginx behind Cloudflare is 2. The count is how many entries from the right of the header are treated as the proxies' own word, so setting it too high is worse than too low — it starts trusting values the client supplied. With 1, a client that sends its own X-Forwarded-For gets nginx's $proxy_add_x_forwarded_for appended after it, and the forged value is ignored.

Leave the last three at 0 unless your proxy sets them on every request. A header the proxy never writes is passed through exactly as the client sent it, so trusting X-Forwarded-Host when nothing overwrites it lets any client choose the host the app builds its links from.

Set RT_PROXY_X_FOR=0 and RT_PROXY_X_PROTO=0 when the app is reachable directly, where those headers are only ever a client's opinion.

A check: after docker compose up -d, a request carrying the header should be logged under it.

$ curl -s -o /dev/null -H 'X-Forwarded-For: 203.0.113.7' http://localhost:8080/
$ docker compose logs web --tail 1
rubiks-trainer-web  | 203.0.113.7 - - [...] "GET / HTTP/1.1" 200 ...

The /healthz lines stay at 127.0.0.1: those are the container's own health check, which really is a local request and carries no header.

Upgrading#

cd rubiks-trainer
docker compose down

git pull
docker compose build web        # only needed if requirements.txt changed
docker compose up -d

docker compose logs -f web

Your data is in the rt-data volume and is not touched by any of this. Your config.yml and .env are not in git and are not overwritten — but check config.example.yml after an upgrade for keys that were added, since new keys fall back to their built-in defaults rather than erroring.

There is no migration framework

The schema is created with create_all(), which adds missing tables but never alters existing ones. A release that changes a column will say so in its notes and tell you what to do. Take a backup before upgrading.

Re-importing the library after an upgrade#

If a release ships new or corrected cases:

docker compose run --rm web flask seed

This matches on the case key and updates in place, so your attempts and assessments stay attached.

Uninstalling#

docker compose down -v          # -v also removes the rt-data volume
docker image rm rubiks-trainer:latest

-v deletes every profile, assessment, attempt and solve. Take a backup first if there is any chance you want them back.

Source: docs/install/index.md