Podfall is a platform for interactive programming tasks. An author describes a task — the services it needs, the starter files, and the checks that define "solved" — and a learner gets a workspace in the browser where their code runs in a sandboxed Kubernetes pod and is graded automatically.
Built solo as a learning project (March 2026 – present) to understand how a multi-service Spring Boot system behaves end to end: authentication, per-service databases, asynchronous events, containerized deployment, and running untrusted code safely.
Stack: Java 25 · Spring Boot 4.1 · PostgreSQL · Redis · Gradle · Docker · Kubernetes (Helm) · React 19
- What it does
- Architecture
- How a session works
- Implementation notes
- Running it locally
- Testing and CI
- Deployment
- Known limitations
For learners. Browse a task catalog, open a task, and get a workspace with a file tree, a terminal, and live container logs. Your code runs server-side, so nothing has to be installed locally. Pressing "verify" runs the task's checks and returns a per-check result.
For authors. A wizard for defining a task: which services it spins up (an app container plus optional PostgreSQL, MySQL, MongoDB, Redis, RabbitMQ, Kafka or Elasticsearch), the starter file bundle, and the checks that grade a submission. Bundles are uploaded to S3-compatible storage and reviewed before publishing.
Checks are the core idea — a task is defined by what must be observably true of a running solution, not by comparing output strings:
| Check type | What it does |
|---|---|
HTTP |
Calls an endpoint of the running solution and asserts on status code and response body |
LOG |
Matches container logs against a substring or regex |
DATABASE |
Runs a read-only query against the session's database; any statement other than SELECT is rejected |
QUERY |
Queries a non-relational service (Redis, MongoDB) through the container's CLI |
COMMAND |
Executes a shell command inside the container and asserts on its result |
TEST_SUITE |
Runs the task's own test command and reports the outcome |
HTTP and database checks are wrapped in a retry layer, because a freshly started container is usually not ready on the first attempt.
Eight backend services, a React frontend, and a shared library. Each backend service owns its own PostgreSQL database and its own Flyway migrations — no shared schema.
| Service | Port | Responsibility |
|---|---|---|
frontend |
8080 | React 19 + TypeScript + Vite + Tailwind SPA |
auth-service |
8081 | Registration, login, JWT issuing, refresh tokens, OAuth2, subscription tier |
catalog-service |
8082 | Published task catalog: browsing, search |
creator-service |
8083 | Task authoring, bundle upload, review and publishing |
runtime-service |
8084 | Kubernetes session orchestration, workspace, terminal, logs, verification, grading |
notification-service |
8085 | Transactional email, triggered by events |
progress-service |
8086 | Per-user completions and progress stats |
analytics-service |
8087 | Aggregates grading events into platform metrics |
payment-service |
8088 | Stripe Checkout, billing portal, subscription webhooks |
platform-core |
— | Shared library: JWT validation filter, S3 access, rate limiting, observability config, event DTOs |
runtime-service is by far the largest and most interesting service; catalog, progress, analytics and notification are deliberately thin.
flowchart TD
Browser --> Ingress["Traefik ingress"]
Ingress --> Auth["auth-service"]
Ingress --> Catalog["catalog-service"]
Ingress --> Creator["creator-service"]
Ingress --> Runtime["runtime-service"]
Ingress --> Payment["payment-service"]
Creator -->|"publishes task"| Catalog
Creator -->|"task bundle"| S3[("S3 / MinIO")]
Runtime -->|"reads bundle"| S3
Payment -->|"activates tier"| Auth
Runtime --> K8s["Kubernetes API"]
K8s --> Session["session namespace<br/>(gVisor pod + services)"]
Auth --> Rabbit[["RabbitMQ"]]
Runtime --> Rabbit
Rabbit --> Notification["notification-service"]
Rabbit --> Analytics["analytics-service"]
Auth --> Redis[("Redis")]
Runtime --> Redis
- The learner starts a task.
runtime-servicechecks their subscription tier against monthly usage limits. - A dedicated Kubernetes namespace is created for the session, with a
NetworkPolicyrestricting traffic and RBAC scoped to that namespace. - If the task needs a custom image, it is built inside the cluster with Kaniko — a Job that builds and pushes without a Docker daemon, so no container runtime socket is ever mounted.
- Pods are scheduled with the gVisor
RuntimeClass. gVisor intercepts the container's system calls in a user-space kernel instead of passing them to the host kernel, which shrinks the attack surface for code the platform did not write. This is why the platform never needs Docker-in-Docker or anything installed on the learner's machine. - The workspace loads the task's file bundle from S3; the terminal and log streaming both go through the Kubernetes API (
execand log endpoints) rather than a custom agent inside the container. - On verify, each check runs against the live session (HTTP call, log read, read-only SQL, or
exec), results are scored against the task's rubric, the attempt is persisted, and aGradingCompletedEventis published for analytics. - Idle sessions are reaped by a cleanup job, which deletes the namespace and everything in it.
Things that took the most thought, and where the interesting code lives.
Authentication (auth-service, platform-core). Short-lived JWT access tokens plus refresh tokens persisted in PostgreSQL and rotated on every use, so a leaked refresh token has a bounded lifetime and can be revoked server-side. Tokens are delivered as HttpOnly Secure SameSite=Strict cookies and stripped from response bodies, which keeps them out of reach of page scripts. Other services don't call auth on every request — they validate the signature locally through a shared filter in platform-core.
Abuse protection. Rate limiting is distributed rather than per-instance: Bucket4j token buckets in Redis, configured per endpoint. Repeated failed logins escalate through tiered lockouts (temporary, extended, freeze) and trigger a Cloudflare Turnstile CAPTCHA, with a circuit breaker so a Turnstile outage doesn't take login down with it.
Read-only database checks (DatabaseCheckExecutor). Task authors can assert on the state of a session database, but a check must not be able to mutate or destroy it. The executor parses and rejects anything that isn't a SELECT before the statement reaches the driver.
Sandboxing. gVisor RuntimeClass plus per-session namespaces, NetworkPolicy isolation, non-root containers, readOnlyRootFilesystem, dropped capabilities, and the RuntimeDefault seccomp profile. Kaniko removes the need to expose a Docker socket for image builds.
Events. RabbitMQ carries UserRegisteredEvent, PasswordResetRequestedEvent and GradingCompletedEvent. Publishers sit behind an interface with a no-op implementation, so the whole platform runs without a broker when messaging is switched off — useful locally and in tests.
Local development runs on a real local Kubernetes cluster rather than Docker Compose, because the gVisor sandbox and namespace-per-session model have no Compose equivalent.
Prerequisites: Git, Docker, kubectl, Helm.
git clone https://github.com/kzhunmax/podfall.git
cd podfall
# k3s: installs gVisor (runsc), builds images, deploys the Helm charts
./scripts/k3s-dev-up.sh
# or, for a kind-based cluster
./scripts/dev-k8s-up.shOnce the pods are healthy the application is on http://localhost. Grafana is a ClusterIP service, so reach it with a port-forward:
kubectl -n podfall port-forward svc/grafana 3000:3000
# then http://localhost:3000 — default credentials admin / adminEverything runs without external accounts. Optional integrations — Google/GitHub OAuth2 login, Novu email, Stripe billing, Turnstile CAPTCHA, the S3 malware-scan Lambda — stay disabled until you supply credentials. See deploy/helm/podfall/values-secrets.example.yaml for the full list and override them with -f your-secrets.yaml.
Roughly 130 unit test classes (JUnit 5, Mockito, fabric8's Kubernetes mock server for orchestration code) and 11 integration tests that run against real dependencies in Testcontainers — PostgreSQL, Redis, MinIO, RabbitMQ — plus WireMock for third-party HTTP. Contract tests check that creator-service projections stay compatible with what catalog-service and runtime-service consume.
./gradlew check # unit tests, JaCoCo verification, Spotless
./gradlew intTest # Testcontainers integration tests
./gradlew intTestDocker # opt-in tests needing a real k3s cluster
./scripts/verify-all.sh # everything CI runs, locally
./scripts/verify-all.sh --skip-build # without Docker builds
cd frontend && npm ci && npm run test:coverageGitHub Actions gates:
| Workflow | Trigger | Checks |
|---|---|---|
dev-gate-tests |
PR → dev |
Trivy filesystem scan, frontend lint + Vitest, Spotless, unit tests, JaCoCo, Testcontainers integration tests |
prod-gate-tests |
PR → main |
Flyway migrate/validate/idempotency across all 7 schemas, all images built, SBOM, Trivy image scan |
helm |
Helm changes | helm lint, helm template, kubeconform against every values profile, kind smoke install |
publish-*-images |
push to dev, v* tags |
Path-filtered image publishing to GHCR |
Tests that need a real cluster are tagged kubernetes and excluded from PR gates — they run through the manual integration-manual workflow.
Helm charts in deploy/helm/: podfall for the application, podfall-observability for the telemetry stack. Terraform in infrastructure/terraform/ provisions GKE with a gVisor sandbox node pool, Cloud SQL, and Artifact Registry on GCP, plus an S3 bucket and a malware-scan Lambda on AWS. Argo CD manifests in deploy/argocd/ sync the production release from Git.
Observability differs by environment on purpose: locally the full stack runs in-cluster (OpenTelemetry Collector, Prometheus, Loki, Tempo, Mimir, Pyroscope, Grafana), while production ships telemetry to Grafana Cloud through Alloy and keeps nothing stateful in the cluster. scripts/verify-prod.sh fails the deployment if an in-cluster telemetry pod shows up in production.
Honest list of where this project stops.
- Not currently hosted. The GKE cluster is not running, so there is no public URL — the platform has to be started locally. Cost, not capability.
- Grading v2 is behind a flag. The scored-rubric API (
GradingController) is complete but ships withGRADING_V2_ENABLED=false; the earlier verification endpoint is what's active by default. payment-servicehas no automated tests. It was the last service written and Stripe flows were validated manually against test keys. It's the first gap I'd close.- The JaCoCo gate is 20% line coverage, which is a regression floor rather than a target. Coverage is concentrated where it matters — auth, creator and runtime — and thin in the smaller services.
- Cluster bootstrap is manual. Argo CD, External Secrets, Traefik and cert-manager are installed by hand before Argo takes over; only the application release is GitOps-managed.
- Single-region, single-cluster. No multi-region story, no autoscaling tuning, no load testing to establish real capacity limits.
- Frontend is functional, not polished. It exists to exercise the backend.
services/ Spring Boot services, platform-core library, contract tests
frontend/ React 19 + Vite SPA
deploy/helm/ Application and observability Helm charts
deploy/argocd/ Argo CD application manifests
infrastructure/ Terraform (GCP + AWS), observability configs, malware-scan Lambda
scripts/ Cluster bring-up, teardown, and CI-equivalent verification