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#
- Flask creates the database schema (
db.create_all()). - The algorithm library is imported from
app/data/algorithms.json— 41 F2L, 57 OLL and 21 PLL cases with their groups and algorithms. - The built-in training plans are inserted.
- 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-Proto — https 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.