Deploy¶
The runbook, end to end. It is written against DigitalOcean because that is what the reference deployment uses, but nothing here is specific to it: any Ubuntu host with Docker and any PostgreSQL 16 will do, and the only DigitalOcean-flavoured steps are the ones about creating the machine and the database.
Read Overview first if you have not.
Allow an hour for a first deploy, most of it waiting for DNS and for a database to provision.
1. Create the infrastructure¶
The host. Ubuntu 24.04 LTS, 2 vCPU / 4 GB, SSH key authentication only, in a region near your users. If you are using DigitalOcean: a Premium Intel or AMD droplet, in the default VPC for the region, monitoring enabled, tagged grader.
A firewall. Inbound SSH from your own addresses, HTTP 80 and HTTPS 443 (TCP and UDP) from anywhere, all outbound. The host also runs ufw; the cloud firewall is the outer layer.
PostgreSQL 16. Managed, smallest plan, same region and private network as the host, with the host as its only trusted source. Create a database and user both called grader, then take the private network connection string. It looks like:
The application uses psycopg, so the value in .env is that string with the scheme changed to postgresql+psycopg://. Keep ?sslmode=require — a managed cluster refuses unencrypted connections, and you want it to.
DNS. An A record for the hostname pointing at the host's public IPv4. Caddy obtains a Let's Encrypt certificate on first start, so this must resolve before the first deploy or the certificate request fails and you wait out a rate limit.
2. Bootstrap the host¶
From your laptop, with the repository checked out:
That installs Docker, creates a deploy user with the docker group and root's SSH keys, enables unattended security upgrades, opens 22/80/443 in ufw, starts fail2ban, creates /srv/grader, and permits the unprivileged user namespaces the grading sandbox needs. It is safe to re-run.
Then let the host pull the images. If the packages are public, skip this; otherwise create a classic personal access token with only the read:packages scope and:
3. Write .env¶
In /srv/grader, mode 600. Start from .env.example; Configuration is the full list. With GRADER_ENV=prod the service refuses to start if a required secret is missing — that refusal is the point, so do not work around it.
Generate the secrets¶
# Session cookie signing secret
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
# Ed25519 key for notebook tokens (run inside the image, which has the dependencies)
docker run --rm ghcr.io/ljchang/marimo-grader-backend:main \
python -c "from grader.auth.tokens import generate_private_key_pem as g; print(g())"
# SAML service-provider certificate and key, 10 years, self-signed
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout sp.key -out sp.crt -subj "/CN=grader.example.edu"
Multi-line PEM values go into .env on one line with \n separators — pydantic-settings unescapes them inside double quotes:
Delete sp.key from your laptop once it is in .env, and put a copy of .env in a password manager — it is the only copy of both keys.
The values that matter¶
| Variable | Set to |
|---|---|
GRADER_ENV |
prod |
GRADER_HOST |
the hostname Caddy serves and gets a certificate for |
GRADER_BASE_URL, GRADER_FRONTEND_URL |
https://<hostname> — both, in a single-host deployment |
GRADER_DATABASE_URL |
the private connection string, postgresql+psycopg://…?sslmode=require |
GRADER_SESSION_SECRET |
generated above; stable across deploys or everyone is signed out |
GRADER_COOKIE_SECURE |
true |
GRADER_JWT_PRIVATE_KEY_PEM |
generated above |
GRADER_AUTH_MODE |
saml, or disabled until registration completes |
GRADER_SAML_* |
see SAML |
GRADER_CORS_ORIGINS |
every origin a notebook will run on: your course site, https://molab.marimo.io |
GRADER_IMAGE_TAG |
main, or pin a release |
GRADER_ARTIFACT_DIR is set by the image; leave it out.
The setting people forget
GRADER_CORS_ORIGINS. It is not needed to deploy, and not needed to sign in to the web app, so an omission surfaces only when a student presses Submit from the course website and is told the grader could not be reached. Add the origins when you set up, not when someone reports it.
4. Deploy¶
scp docker-compose.prod.yml deploy/deploy.sh .env deploy@<host-ip>:/srv/grader/
ssh deploy@<host-ip> 'chmod 600 /srv/grader/.env && cd /srv/grader && ./deploy.sh'
deploy.sh pulls both images at GRADER_IMAGE_TAG, fixes volume ownership for the non-root container user, runs the migration (a failure here stops the deploy before anything else changes), starts web, worker and proxy, waits for the health check, prunes old images, and prints docker compose ps.
5. Verify¶
curl -fsS https://<hostname>/api/health
# {"ok":true,"env":"prod","auth_mode":"saml"}
curl -sI https://<hostname>/ | grep -iE 'strict-transport|content-security|x-robots'
curl -fsS https://<hostname>/api/v1/auth/saml/metadata | head -3
Then check the sandbox, which is the one thing that fails silently later if it is wrong:
cd /srv/grader
docker compose -f docker-compose.prod.yml exec worker \
bwrap --ro-bind / / --unshare-all --dev /dev --proc /proc -- /bin/true && echo sandbox ok
If that reports No permissions to create new namespace, see Sandbox and data.
6. The first course¶
Start on the command line. This is not the alternative to signing in — on an empty database it is the only way in, for two reasons: grader login-link refuses a NetID that has no user row yet, and a first SAML sign-in creates an ordinary user, because nothing in the sign-in path grants platform admin. Somebody has to be made one from a shell.
docker compose -f docker-compose.prod.yml run --rm web grader seed \
--course neuroimaging --title "Introduction to Neuroimaging Analysis" \
--term 2026-fall --instructor <netid> --students
docker compose -f docker-compose.prod.yml run --rm web grader token <netid> \
--offering neuroimaging/2026-fall --role instructor --admin
seed creates the course, the offering and the instructor — who is also made a platform admin, which is the part that matters here. The trailing --students with nothing after it matters too: seed defaults to two example students, and an empty list is how you say no students on a real server. Real ones arrive by roster import.
Now you can sign in, through SAML if it is registered, or with a one-time link:
Admin mode then has what it needs, and further courses and offerings can be created there. Everything on that screen is platform-level: no student data appears, and an administrator cannot read any.

The token from the second command is valid eight hours and lets an instructor publish from their laptop without a browser:
uv run grader publish assignments/glm.py \
--server https://<hostname> --offering neuroimaging/2026-fall \
--slug glm --title "GLM" --token <token>
7. Before students arrive¶
- Warm the dataset cache if any assignment reads data. Skipping this does not produce zeros; it produces grader failures.
- Import the roster, which also sets up Canvas export.
- Set up backups and test a restore once.
- Submit one assignment yourself, end to end, as a student would. Staff can submit precisely so that this is possible.
Redeploying¶
ssh deploy@<host> 'cd /srv/grader && ./deploy.sh' # latest main
ssh deploy@<host> 'cd /srv/grader && ./deploy.sh sha-1a2b3c4' # a specific commit
ssh deploy@<host> 'cd /srv/grader && ./deploy.sh v0.2.0' # a release tag
A tag passed this way applies to that run only; put it in .env to make it stick. Rolling back is the same command with an older tag — but migrations are forward-only, so roll back the application, not the schema, unless you have checked that the migration in between is reversible.
Prefer deploying between assignment deadlines. Nothing about a deploy affects open student notebooks, but a restart during a submission spike is needless drama.
Useful commands¶
docker compose -f docker-compose.prod.yml logs -f web worker proxy
docker compose -f docker-compose.prod.yml run --rm web grader --help
docker compose -f docker-compose.prod.yml exec web \
python -c "from grader.config import get_settings; print(get_settings().env)"
Hotfixing without CI¶
The host never builds. If you must ship something CI cannot:
docker build -t ghcr.io/ljchang/marimo-grader-backend:hotfix backend
docker build -t ghcr.io/ljchang/marimo-grader-web:hotfix -f deploy/web/Dockerfile .
docker push … && ./deploy.sh hotfix
Then get the change onto main and redeploy from a real build, so the next person does not inherit a mystery tag.