A small, self-hosted platform for running GPU training jobs and short-lived interactive shells across a cluster of worker nodes. Users submit a zip of their code, the system builds a Docker image, schedules the job on a worker that has a free GPU, and (optionally) hands out an SSH shell into the running container.
The repo is laid out as a set of cooperating FastAPI microservices that share a single PostgreSQL database and a single Redis instance.
ββββββββββββββ βββββββββββββββββ
β Frontend ββββββΆβ Scheduler β (FastAPI + Postgres + Redis)
β (UI/) β β /jobs, /auth β
ββββββββββββββ β /interactive β
β² β /workers β¦ β
β ββββ¬βββββββββ¬ββββ
β β β
pull_job β β β ask for SSH key
βββββββββββββββββββ β βΌ
β β βββββββββββ βββββββββββββββ
βΌ β β Gateway ββSSHββΆ β Headscale β
ββββββββββββββ builds images β β(bastion)β β mgmt β
β Worker(s) βββββββββββββββββββββ β ββββββ¬βββββ ββββββ¬βββββββββ
β docker, β β β β 100.x.x.x β pre-auth
β GPU, β ββββββββββββββββ΄ββ β βΌ β key
β nsenter βββββΆβ Docker Image β β βββββββββββββββ βΌ
ββββββββββββββ β Builder β β β Worker β βββββββββββββββ
β βββββββββββββββββ β β interactiveβββββ β Headscale β
β β β container β β control β
βΌ β β + access β β plane β
ββββββββββββββ blobs β β container β βββββββββββββββ
β Object βββββββββββββββββββββββββ β βββββββββββββββ
β Store β
β (MinIO) β
ββββββββββββββ
Everything below is a backend service. The UI/ directory is the React/TS
frontend and is intentionally out of scope for this README.
| Directory | What it is |
|---|---|
Scheduler/ |
FastAPI control plane: auth, jobs, workers, scheduling, interactive sessions, watchdog |
Worker/ |
Agent that runs on each GPU node: registers, pulls jobs, runs Docker containers, streams logs |
Docker_Image_Builder/ |
Polls Scheduler for unbuilt jobs, builds + pushes Docker images to Docker Hub |
Object_store/ |
FastAPI in front of MinIO; upload / download / presigned URLs / listing |
gateway/ |
SSH bastion. Owns per-session SSH keys, runs its own Tailscale node, proxies SSH into interactive containers |
headscale_mgmt/ |
Thin wrapper over Headscale's CLI/REST for minting + revoking Tailscale pre-auth keys |
AccessContainer/ |
Dockerfile + entrypoint for the shared aorko123/access-sshd:latest image used by interactive sessions |
Scheduler/docker-compose.yml brings up db (Postgres), redis,
api (Scheduler), headscale-mgmt, gateway, and pgadmin.
Object_store/docker-compose.yml brings up minio + object-store.
Workers run on separate hosts with their own docker run / systemd unit;
they are not part of Scheduler/docker-compose.yml.
- User authenticates via
POST /auth/loginβ JWT. POST /jobs/submit_jobwith a zip (requirements.txtrequired), command, anddocker_base_image. The Scheduler:- uploads the zip to the Object Store (
POST /objects/upload), - creates a
Jobrow (status = NOT_RUNNABLE).
- uploads the zip to the Object Store (
- The Dashboard polls
/jobs/mineand shows a live log feed overWS /jobs/{job_id}/logs/stream(Redis Streams under the hood).
Docker_Image_Builder/builder.pypollsGET /jobs/unbuilt_jobs, downloads the archive, generates a Dockerfile, builds the image, and pushes it to Docker Hub as<DOCKER_HUB_USERNAME>/<job_id>:latest.- Streams build output to
POST /jobs/logs/{job_id}and uploads a throttledbuild.logto the Object Store. - On success calls
POST /jobs/update_job_to_vram_estimation_pendingβ statusVRAM_ESTIMATION_PENDING.
- Workers calling
POST /jobs/pull_jobget dispatched either a training job, a retry, a VRAM-estimation probe, or an interactive session. The scheduler picks the worker with the highest free VRAM for the probe. - The worker runs
Worker/vram_estimation.pyinside the user's image (it monkey-patchestorch.optim.Optimizer.__init__, runs the user's script withrunpy, then exits after a few steps). Result is posted back asPOST /jobs/save_vram_estimation{peak VRAM, RAM, step_time}β statusRUNNABLE.
- Workers poll
POST /jobs/pull_job{worker_id, gpu_type, free_vram}everyJOB_POLL_INTERVAL(default 10s). - The Scheduler picks in this order: (1)
INTERACTIVE_READYsession on an idle worker (failover-aware: won't redispatch to the worker that just lost it), (2) VRAM estimation on the highest-free-VRAM worker, (3)RETRY_NEEDEDjobs, (4)RUNNABLEtraining jobs (ordered byvram_required DESC, thencreated_at ASC). - The Worker pulls the image from Docker Hub, mounts a per-job output
dir, runs the user's command, streams logs back, and continuously
uploads changed files to the Object Store via
OutputFileMonitor(every 2 s). - On restart, a worker first calls
POST /jobs/resumeto reclaim its in-progress job before the scheduler's stall watchdog requeues it.
POST /jobs/mark_completedβCOMPLETED, computesgpu_hour.POST /jobs/mark_failed{failure_type: "user"|"system"}βFAILEDorRETRY_NEEDED(system failures are re-dispatched later).- A background watchdog (
Scheduler/app/services/watchdog_service.py, started in the FastAPI lifespan) requeues any job whose worker stopped heartbeating for >180 s.
Interactive sessions let a user SSH into a long-running container on a worker, with no public inbound port on the worker and no SSH keys on the user side. The components:
User ββsshβββΆ Gateway (Tailscale node, port 2222)
β on login, validate one-time password against
β Scheduler's /interactive/sessions/verify-ephemeral
β and learn session_id + headscale_ip (100.x.x.x)
βΌ
Container (worker, nsenter'd via the shared access image)
β headscale tailscaled on the same tailnet
β sshd ForceCommand β nsenter -t 1 ... β env container
Key data model: Scheduler/app/models/interactive_session_model.py
(InteractiveSession with session_id, job_id, user_id,
headscale_ip, worker_id, last_worker_id, ssh_public_key,
headscale_auth_key, and a 6-state enum).
- Create.
POST /jobs/submit_interactive(derived from an existing training job's image) orPOST /jobs/submit_interactive_direct(with a fresh upload + env spec).Scheduler/app/services/interactive_service.py:create_session:- creates a
Jobrow, - asks
gatewayfor a session SSH keypair (POST /keys), - asks
headscale_mgmtfor a Tailscale pre-auth key (POST /auth-keys, default 1 h TTL), - persists
InteractiveSessionasPENDING.
- creates a
- Build.
Docker_Image_Builderbuilds the env image (or reuses the base job's image) and postsPOST /jobs/mark_interactive_readyβINTERACTIVE_READY. - Dispatch. First idle worker calling
/jobs/pull_jobclaims the session (SELECT β¦ FOR UPDATE), setsINTERACTIVE_DEPLOYING, and gets a payload withenv_image_tag,access_image_tag=aorko123/access-sshd:latest,headscale_url,headscale_auth_key,ssh_public_key. - Deploy.
Worker/interactive_handler.pylaunches a clean env container (sleep infinity) and an access container with--pid container:<env> --cap-add ALL --device /dev/net/tun. The access entrypoint (AccessContainer/access-entrypoint.sh) startstailscaled, joins Headscale, installs the gateway's public key forsandbox, and starts sshd withForceCommand nsenter -t 1 -m -u -i -n -p -- /bin/bash -lso logins land inside the env container. The worker pollsdocker logsforTailscale IP: 100.x.x.xand posts it toPOST /interactive/report_ipβINTERACTIVE_RUNNING. - Connect.
GET /jobs/{job_id}/connect(JWT) returns the gateway host/port, the user's username,container_user=sandbox, and a short-lived password minted byScheduler/app/services/ephemeral_password_service.py(in-memory, SHA-256 keyed, time-windowed with grace extension on first use, one-valid-per-session). The gateway'scheck_auth_passwordcallsPOST /interactive/sessions/verify-ephemeralon the Scheduler, consumes the password, and is told thesession_idandheadscale_ip.gateway/ssh_server.pythen opens an upstream SSH channel to the container and proxies bytes bidirectionally (including window-resize pty requests). - Stop.
POST /interactive/{session_id}/stopflags the sessionSTOPPING. Because workers are not directly reachable, the stop command rides on the worker's next heartbeat response instop_sessions[]; the worker tears down the containers and reportsSTOPPED. Keys on the gateway are deleted and the Headscale key is revoked.
Auto-kill timers in the watchdog:
- 10 min after start with no SSH connect,
- 6 h hard cap from start,
- 30 min idle (no login) after connecting.
Failover: if a worker or its container stops heartbeating, the session
is reset to PENDING and last_worker_id is remembered so the next
dispatch avoids the same machine.
| Service | Storage |
|---|---|
Scheduler |
PostgreSQL (jobs, users, workers, interactive_sessions, resource_requests) + Redis (heartbeats, log streams, job_worker:<id> mapping, interactive_heartbeat:<sid>) |
Worker |
Local Docker + per-job output dir + running_job.json (crash-recovery marker) |
Object_store |
MinIO bucket uploads/ (source archives) and outputs/ (training artifacts, build logs) |
Docker_Image_Builder |
Local SQLite (processed_jobs, base_images) for de-dup + pruning |
gateway |
Local volume gateway-keys (/data/ssh-keys/) β session Ed25519 privkeys + the gateway host key |
- Users β Scheduler. JWT (HS256, 30-min default). Decoded in
Scheduler/app/utils/auth.pyandapp/api/deps.py. WebSocket endpoints accept the token via the?token=query param. - Workers β Scheduler. No JWT; identity is the worker UUID and the
network boundary. Heartbeats ride
/workers/heartbeat. Stop commands for the worker ride in the heartbeat response (stop_sessions[]). - Internal callbacks (
/jobs/update_job_to_vram_estimation_pending,/jobs/mark_interactive_ready,/jobs/mark_failed,/jobs/save_vram_estimation,/interactive/report_ip) are unauthenticated and rely on the same network boundary. - Gateway β Scheduler. The gateway posts
/interactive/sessions/verify-ephemeral{username, password}and learns thesession_id/headscale_ipon success. headscale_mgmtβ Scheduler.Authorization: Bearer ${HEADSCALE_MGMT_AUTH_TOKEN}.
All services are configured via environment variables; the canonical list
lives in each service's config.py. Key variables (defaults shown):
| Var | Default | Used by |
|---|---|---|
DATABASE_URL |
postgresql://admin:admin@db:5432/app |
Scheduler |
REDIS_HOST / REDIS_PORT |
redis / 6379 |
Scheduler |
JWT_SECRET_KEY / JWT_ALGORITHM |
your-secret-key-change-in-production / HS256 |
Scheduler |
OBJECT_STORE_URL |
http://localhost:8010 |
Scheduler, Worker, Builder |
OBJECT_STORE_BUCKET / OBJECT_OUTPUT_BUCKET |
uploads / outputs |
Scheduler, Builder |
GATEWAY_API_URL |
http://gateway:8200 |
Scheduler |
HEADSCALE_MGMT_URL |
http://headscale-mgmt:8100 |
Scheduler |
HEADSCALE_MGMT_AUTH_TOKEN |
change-me |
Scheduler, headscale_mgmt |
HEADSCALE_URL |
https://headscale.example.com |
Scheduler, Worker, gateway |
GATEWAY_PUBLIC_HOST |
"" |
Scheduler, frontend |
GATEWAY_SSH_PORT / GATEWAY_PUBLIC_SSH_PORT |
2222 / 443 |
Scheduler, gateway |
SCHEDULER_URL |
(required) | Worker |
DOCKER_HUB_USERNAME / DOCKER_HUB_PASSWORD |
aorko123 / "" |
Worker, Builder |
AUTH_TOKEN |
change-me |
headscale_mgmt |
main.pyβ FastAPI app, lifespan starts the watchdog, mounts routers.db/database.pyβ SQLAlchemy engine + lightweight additive migrations.core/redis.pyβ shared async Redis client.api/jobs_route.pyβ job submit / pull / resume / logs / connect info.api/interactive_route.pyβ interactive create / report_ip / verify-ephemeral / stop.api/auth_route.py,api/worker_route.py,api/scheduler_route.py,api/docker_route.py,api/resource_route.py,api/deps.pyβ rest of the HTTP surface.services/job_service.pyβ scheduling strategies and state transitions.services/interactive_service.pyβ full interactive-session orchestration.services/ephemeral_password_service.pyβ time-windowed SSH password store (multi-use within validity window).services/watchdog_service.pyβ stalled jobs, stalled sessions, auto-kill timers.services/worker_service.py,services/log_service.py,services/scheduler_service.py,services/resource_service.py,services/auth_service.pyβ supporting services.models/βuser_model.py,worker_model.py,job_model.py,interactive_session_model.py,resource_request_model.py.schemas/β Pydantic request/response models for every route.utils/auth.pyβ bcrypt + JWT helpers;utils/file_utils.pyβ zip validation + Object Store upload.
main.pyβ entrypoint, three daemon threads (heartbeat, job poll, API server).server.pyβ local FastAPI dashboard (/api/worker,/api/metrics,/api/gpus,/ws/metrics, pause/resume,/api/config).api.pyβSchedulerAPIHTTP client.executor.pyβ flag dispatcher;handle_training,handle_retry,handle_vram_estimation,handle_interactive, plusresume_persisted_job_if_any.interactive_handler.pyβ two-container SSH-over-Tailnet launcher.output_monitor.pyβ background uploader of job artifacts.object_store.pyβ MinIO client with presigned URL bypass for >50 MB.job_state.pyβ atomicrunning_job.jsonfor crash recovery.io_monitor.py,telemetry.py,runtime_config.py,hardware.py,vram_estimation.py,config.py.
builder.pyβ poll loop; per-tickscan_and_process.docker_ops.pyβ Dockerfile generation, Docker SDK calls, log streaming.api.pyβ HTTP client wrappers for the Scheduler + Object Store.database.pyβ SQLite tracker.config.py.
main.pyβ FastAPI + lifespan (start Tailscale, start SSH server).api.pyβ/health,/keys,/keys/{id},/connect.ssh_server.pyβ paramiko SSH bastion (GatewayServer,_proxy_shell,start_ssh_server).session_client.pyβ outbound SSH to containers.ssh_key_manager.pyβ per-session Ed25519 keypairs.config.py.
main.pyβ FastAPI app + CORS.api.pyβ/auth-keys,/auth-keys/{key}with bearer-token auth.headscale_client.pyβ CLI-or-HTTP abstraction over Headscale.schemas.py,config.py.
main.pyβ FastAPI wrapper over MinIO with both internal and public endpoints.init_buckets.pyβ idempotent bucket creation.docker-compose.ymlβminio+object-store.
Dockerfile+access-entrypoint.shβ the sharedaorko123/access-sshd:latestimage (sshd + tailscaled + nsenter).
# 1. Object store (MinIO + FastAPI wrapper)
cd Object_store && docker compose up -d
# 2. Scheduler + gateway + headscale-mgmt + postgres + redis
cd ../Scheduler
# .env must set at minimum: JWT_SECRET_KEY, PGADMIN_DEFAULT_PASSWORD,
# HEADSCALE_URL, HEADSCALE_API_KEY, GATEWAY_TAILSCALE_AUTH_KEY,
# GATEWAY_PUBLIC_HOST, POSTGRES_PASSWORD.
docker compose up -d --build
# 3. Docker Image Builder (separate host or sidecar)
cd ../Docker_Image_Builder && docker compose up -d --build
# 4. Worker (on each GPU host)
cd ../Worker
# SCHEDULER_URL=http://<scheduler-host>:8000 in .env, then:
python main.py- CORS is wide open in every service (
allow_origins=["*"]); tighten for real deployments. - Schema migrations are lightweight
ADD COLUMN IF NOT EXISTS; use Alembic for production. - Internal worker/builder callbacks are unauthenticated; rely on the network boundary.
- The shared
aorko123/access-sshd:latestimage is the only path into an interactive container; itForceCommands the shell throughnsenterinto the env container's PID 1. - Workers have no inbound-reachable endpoint β all commands ride on heartbeat responses.