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 .envThe 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 oneThere 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_NETWORKSdecides 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/24is 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,noneis a complete answer.DOCKER_GIDis the group that owns the daemon's socket, andpreflight.shtells you if yours is wrong. See What the host needs.COMPOSE_PROJECT_NAMEnames this installation, and empty meansalgojudge. 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 firstup.
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 reloadWith no certificate, for a first look:
./scripts/render-tls.sh your.domainSelf-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 --waitor 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 passwordThen 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 passwordThe 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.shNothing 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-Runnerupdate.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.