AlgoJudge documentation0.2

First install

From an empty directory to an installation that judges a submission.

Set AJ_ADMIN_TOKEN before the first `up`

While that value is empty, /api/v1/admin/** is closed — and that includes the endpoint that sets the administrator's password. The seeded admin account's password is twenty random characters that are never logged, never printed and never stored anywhere readable.

And not the development one. admin-token-development-only — the value the product repositories' own development Compose files carry — is long enough to pass the length check and still closes /admin, so preflight.sh refuses it by name. With it set, maintenance.sh cannot open or close the installation either, which stops backup.sh --quiesce, update.sh and restore.sh at that step. Generate your own: openssl rand -base64 36.

An installation started without this token has no administrator anybody can sign in as, and the only fix is to drop the database and start again.

The images, and why a new one may not pull

The eight packages — algojudge-server, algojudge-client, algojudge-runner, lang-gcc, lang-clang, lang-python, lang-pypy and algojudge-external-runner — are published and public. docker compose pull needs no registry login, from anywhere.

A GHCR package created by its first push is private, though, and no workflow can change that: somebody with access to the organization's packages sets it to Public, once. It is worth knowing because it returns with every new image, and the symptom is a denied on docker compose pull in an installation that is otherwise correct.

The four language images are not services

docker compose pull fetches six of the eight. lang-gcc, lang-clang, lang-python and lang-pypy are values the Runner is handed rather than services, so Compose never touches them. scripts/pull.sh is what fetches them, and make up runs it; scripts/update.sh does the same on the update path. The Runner fetches them at start as well, and will not register without them.

Skipping that step was the whole of a real failure: until 2026-09-16 nothing on the install path fetched these at all, so an installation that was installed and never updated had no toolchain and every submission failed.

1. Clone and configure

git clone https://github.com/AlgoJudge/AlgoJudge-Ops.git /opt/algojudge-ops
cd /opt/algojudge-ops
git checkout "$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1)"
cp .env.example .env
chmod 600 .env

The newest release, not main. main is where the next release is being written, and what it holds has not been released. update.sh keeps to the same rule: it moves the checkout only from a release to a newer one.

The rest of this page follows main. The release you checked out has its own copy of these instructions: docs/INSTALL.md in the checkout, and this page in that release's version — for 0.1, First install. v0.1.0 asks for a third value, RUNNER_WORK_DIR, has no scripts/pull.sh, and its own update.sh predates the rule above: it runs git pull, which on a release checkout only warns. Move such an installation to the next release by hand once, with git fetch --tags and git checkout <tag>.

Two values have no default and the stack will not start without them. Generate each separately — reusing one across two of these means one disclosure is two compromises.

AJ_ADMIN_TOKEN=          # openssl rand -base64 36
POSTGRES_PASSWORD=       # openssl rand -base64 36, a different one

There is no third, and nothing to create on disk. The Runners keep everything they download, prepare and work on in Docker volumes, which the daemon creates root-owned and mode 0755 without being asked. That is exactly what they need — a Runner writes as root and every job container mounts the volume read-only and reads it as uid 65534. Where the Runners keep their bytes has the two volumes and what each of them holds.

This host's Docker has to be Engine 26 (Podman 5) or later, and there is no arrangement that avoids it: a judge's container mounts a subdirectory of the shared cache volume, which is a subpath mount and arrived there, and a Runner on an older daemon refuses to judge. preflight.sh refuses first.

Three more worth reading before the first start:

  • TRUSTED_PROXY_NETWORKS decides whose word the Server takes for a visitor's address, and the Server refuses to start without it. It defaults to the Compose network the bundled nginx sits on. It must be a network address: 172.28.0.5/24 is refused at startup, by name, with the address it should have been. Behind your own proxy, put that proxy's network here; reached with no proxy at all, none is a complete answer.
  • DOCKER_GID is the group that owns the daemon's socket, and preflight.sh tells you if yours is wrong. See What the host needs.
  • COMPOSE_PROJECT_NAME names this installation, and empty means algojudge. Compose puts it in front of every container, network and volume, and the Runners' cache and scratch carry it too — so two installations on one host that both leave it empty share the Runners' scratch as well as the database. Give each its own name before the first up.

2. A certificate

Put fullchain.pem and privkey.pem in certs/. They are mounted read-only; nothing here issues a certificate and renewal stays whatever you already use.

/.well-known/acme-challenge/ is served on both ports, so an HTTP-01 challenge is never redirected away. After a renewal:

docker compose exec nginx nginx -s reload

With no certificate, for a first look:

./scripts/render-tls.sh your.domain

Self-signed, and every browser will say so. It exists so that a first start produces a working instance with a warning rather than an nginx that will not start and an error nobody can trace back to a missing certificate. It refuses to overwrite a certificate that is already there.

3. Start

./scripts/preflight.sh
./scripts/pull.sh
docker compose up -d --wait

or make up, which is those three.

preflight.sh refuses with a sentence rather than halfway up: an empty password, a missing, short or well-known token, a CIDR with host bits, a daemon too old for the Runners' cache, a cpuset naming a processor this host does not have, more lanes than the host has processors, cgroup v1 or an unknown cgroup driver, a .env that is tracked by Git.

Two of those refusals are about processors, and both of them keep a stack from half-starting. A cpuset naming a processor the host does not have is refused by the daemon at create — Requested CPUs are not available — so up -d --wait would stop with part of the stack running. And a Runner that cannot give every lane a processor of its own refuses to start, which restart: unless-stopped turns into a loop; both cpusets ship empty, which is every processor the host has, so there RUNNER_TESTS_AT_ONCE is measured against the host's own count.

pull.sh fetches several gigabytes on a host that runs Runners and takes minutes. That is the compiler toolchain, and it is normal. It is also the step Compose cannot do for you, because the four language images are not services.

The runner profile starts runner-1 and runner-2, and that is the whole fleet. Each of them judges as many of one submission's tests at once as RUNNER_TESTS_AT_ONCE says, one lane each, so what has to fit the host is the lanes rather than the Runners: more lanes than physical cores does not judge faster and does stop judging accurately. A host with capacity to spare widens the two rather than gaining a third — What the host needs has the table.

4. The administrator

docker compose exec -it server aj-admin password

Then sign in at https://your.domain/ as admin.

The tool prompts without echoing and never takes the password as an argument — arguments are visible to every other process through ps and land in shell history. Scripted, it reads the first line of standard input:

printf '%s' "$NEW_PASSWORD" | docker compose exec -T server aj-admin password

The password policy applies, twelve characters minimum, and a refusal changes nothing — the old password still works, so a typo cannot leave an installation with no way in. It also clears the lockout, since ten wrong guesses is what somebody does on the way to this command.

aj-admin runs inside the Server's container and reads the token from that container's own environment, so the token never enters your shell history. It is the only supported way to reach the operator's surface: /api/v1/admin/** answers on the Server's own loopback interface, and a request through nginx — or through the published 127.0.0.1:8080 — arrives as the bridge gateway and gets a 404. That is measured behavior, not a guess.

5. Approve the Runners

A new Runner registers and then waits. It is not a fault and there is no timeout. Nothing is judged until an administrator approves it, which is what stops somebody from attaching a machine of their own to your installation.

In the panel: Runners, and approve each of the two that appeared. Their logs say waiting: this Runner has not been approved yet until you do, backing off between attempts because a room of Runners registered together would otherwise ask in one burst for as long as it takes somebody to reach the panel. An unapproved Runner is simply idle and the others carry the queue, so a forgotten approval reads as a slow installation rather than as an error.

Afterwards, submit something and watch it get a verdict. Until that has happened once, the installation is not known to work.

6. Optionally, the schedule

./scripts/install-cron.sh --print     # what it would install
./scripts/install-cron.sh

Nothing schedules itself. See Routine work and the schedule for what it installs and what it deliberately leaves commented out.

Running against locally built images

This is not how the stack is normally run — the published images are, and they resolve for anyone. It is for running against code that is not released: a change in a product repository, a fix on its way to a pull request. Build it, tag it where the published one would be, and compose.yaml runs unmodified. -f is relative to where you are standing, not to the build context — the Server's line is the only one with a path in it, which makes it the one worth checking.

Rebuild rather than reuse a tag you already have: these are pinned by a moving tag, so a stale local :0 is silently whatever you built last month.

docker build -f AlgoJudge-Server/AlgoJudge.Server/Dockerfile -t ghcr.io/algojudge/algojudge-server:0 AlgoJudge-Server
docker build -t ghcr.io/algojudge/algojudge-client:0 AlgoJudge-Client
docker build -t ghcr.io/algojudge/algojudge-runner:0 AlgoJudge-Runner
# The context is `images`, not `images/$lang`: all four build the measuring
# shim from `images/shim`, so it has to be inside what they are given.
for lang in gcc clang python pypy; do
    docker build -t "ghcr.io/algojudge/lang-$lang:0" \
        -f "AlgoJudge-Runner/images/$lang/Dockerfile" AlgoJudge-Runner/images
done

# Only for the external Runner's profile.
docker build -t ghcr.io/algojudge/algojudge-external-runner:0 AlgoJudge-External-Runner

update.sh still needs a registry to pull from. A local registry:2 with REGISTRY=localhost:5000/algojudge is how the update and rollback paths are exercised without a published one.

On this page