Deployment overview
Internal doc. Describes how production actually runs today, taken from the code and the deploy workflow, not from older notes.
What runs where
Production is a single campus Linux server (server 7, cs-reed-07), reachable from the campus network at sjsu.cs30.app. No Kubernetes, no docker-compose, no reverse proxy. The backend terminates TLS itself and binds port 443 directly. The pieces:
- Backend — the unified
cs30.jar, run by thecs30.servicesystemd unit as usercs30backend. Listens on 443 over HTTPS and serves the API and the bundled web frontend. It’s a non-root user, so the unit grantsAmbientCapabilities=CAP_NET_BIND_SERVICEto let it bind the privileged port. - Judge —
kt-judge.jar, run by thekt-judge.serviceunit as usercs30judge, listening on 8000 (localhost only). It grades a submission by running thejudge-sandboxDocker image in a throwaway container, so its unit hasAfter=docker.service/Requires=docker.serviceandcs30judgeis in thedockergroup. - PostgreSQL — local on the same host.
- Git repositories — the problem pool and student repos live on the host filesystem; the backend writes to them directly.
- GitHub Actions self-hosted runner — runs on the server as
github-runner, so deploys happen locally with no SSH or inbound access from GitHub’s cloud.
The backend calls the judge at judge.url (http://localhost:8000) on startup and for every submission.
How a release gets there
flowchart TB
push["Push to main"] --> build["build-test (GitHub-hosted):\nrun tests + build cs30.jar and kt-judge.jar"]
build --> art["upload cs30-<sha> + kt-judge-<sha> jar artifacts"]
art --> deploy["deploy-prod (self-hosted runner):\ndownload the jar artifacts"]
subgraph server["Production server (/opt/cs30)"]
deploy --> ghcr["docker pull judge-sandbox:latest\n(GHCR — published by release.yml, not by this run)"]
deploy --> rel["releases/<sha>/{cs30.jar, kt-judge.jar}"]
rel --> link["current -> this release"]
link --> rj["restart kt-judge (non-fatal)"]
rj --> rb["restart cs30 (deploy gate)"]
rb -->|443 /health ok| done["prune to last 5"]
rb -->|fails| rollback["symlink back, restart BOTH services"]
end
Build runs on a GitHub-hosted runner; deploy runs on the self-hosted runner on the server (the only one that can reach the campus network and local systemd). The deploy is a file copy, a symlink flip, and service restarts. Full pipeline on the CI/CD page.
On-server layout
/opt/cs30/
current # symlink to the active release
releases/<sha>/ # one dir per deployed release (last 5 kept)
cs30.jar
kt-judge.jar
application.properties # config, content overwritten from the repo each deploy
cs30.env # secrets (DB password, Google client secret), mode 0600
/etc/ssl/cs30/
fullchain.pem # TLS cert (referenced by application.properties)
privkey.pem
/etc/systemd/system/
cs30.service # backend unit (user cs30backend)
kt-judge.service # judge unit (user cs30judge)
The tracked deploy/cs30.service and deploy/kt-judge.service are reference copies. The units that actually run are the installed ones under /etc/systemd/system/; if you change a unit, edit the installed one and daemon-reload.
The release model
A release is a directory named after the git SHA holding both jars. current points at the live release. Deploying = new release dir → point current at it → restart the judge, then the backend. Rolling back = point current at an older release and restart both. The deploy keeps the last 5 release dirs.
Rollback only reverts the jars, not the database. Schema is managed by Hibernate spring.jpa.hibernate.ddl-auto=update, so rolling back across a schema change can leave the DB ahead of the code — check what changed before rolling back over a migration.
It does not revert the judge sandbox image either. The deploy pulls the mutable :latest, which only the release workflow updates. To roll the image back, point judge.image at an older judge-sandbox:v1.2.3 tag and restart the judge — those per-release tags never change.
Permissions model
CI never sets file permissions — the deploy job copies content with cp (not install), so it preserves whatever owner/group/mode the server already set. Permissions are owned by the server and set once by two scripts:
scripts/setup-service-users.sh— creates thecs30backendandcs30judgeusers and thecs30problemsgroup, putscs30judgeindocker, and sets the deploy dir/opt/cs30to root-owned with per-user ACLs grantingcs30backendandcs30judgeread (plus default ACLs so copied-in jars/config inherit read).scripts/grant-pool-access.sh— ACLs on the problem pool and student repos:cs30backendgets read/write (it owns and commits them), andcs30problemsgets read on the pool (the judge sandbox container runs as that group’s gid to read problems). The student repo is backend-only.
cs30problems is the pool-reader / container group; it is not what makes the deploy dir readable (that’s the per-user ACLs above).
Prerequisites set up once on the server
Done once, listed so you know they exist:
- Service users, group, docker membership, and
/opt/cs30ACLs —scripts/setup-service-users.sh. - Problem pool / student repo ACLs —
scripts/grant-pool-access.sh(withjudge.sandbox.group=cs30problemsinapplication.properties). cs30.servicehasAmbientCapabilities=CAP_NET_BIND_SERVICEsocs30backendcan bind 443.- Both systemd units installed under
/etc/systemd/system/and enabled. - The runner’s
GITHUB_TOKENcan read the privatejudge-sandboxGHCR package (package linked to the repo, or made readable), andgithub-runneris in thedockergroup. - TLS cert at
/etc/ssl/cs30/—fullchain.pemandprivkey.pem, matching theserver.ssl.*keys. Let’s Encrypt tooling writes these for you (certbot, or lego under/etc/lego/certificates/); copy or symlink them to/etc/ssl/cs30/and make them readable bycs30backend. Renewal must land in the same place or TLS breaks silently at the next restart. Port 443 open in the firewall (sudo ufw allow 443), along with SSH. - Google OAuth redirect URI
https://sjsu.cs30.app/callbackregistered in Google Cloud. -
PostgreSQL and JDK 21 installed, with a role and database matching the
spring.datasource.*keys in configuration — by default thecs30role owningcs30db:sudo -u postgres psql CREATE USER cs30 WITH PASSWORD '<the PROD_DB_PASSWORD secret>'; CREATE DATABASE cs30db OWNER cs30;There is no migration tool: Hibernate creates the tables on first start via
ddl-auto=update, so an empty database is the correct starting point.
No kernel tuning is required. fs.pipe-user-pages-soft was investigated as a cause of interactive-problem failures and ruled out — see the runbook.