Welcome to Hades, a robust job scheduler designed with scalability in mind. Hades' primary mission is to provide a straightforward, scalable, and adaptable solution for executing containerized workloads in various environments, from educational programming courses to research computing clusters.
📖 Documentation: the full docs live at hades-scheduler.github.io/hades (a Docusaurus site, source in
website/). The in-repodocs/index and per-component READMEs cover the same material for offline/agent use.
Hades embodies several core design principles:
-
Simplicity: Hades focuses on delivering just the essentials required to execute containerized jobs efficiently, without unnecessary complexity.
-
Scalability: Hades has scalability at its core, capable of queuing and executing a vast number of jobs in parallel, making it ideal for large-scale operations.
-
Container-Based: Hades executes jobs within containers, ensuring a high level of isolation and security between workloads.
-
Kubernetes Native: As a Kubernetes-native solution, Hades leverages the power and flexibility of Kubernetes as its primary execution platform for production workloads.
-
Extensibility: Hades is designed to be highly extensible, allowing for easy integration with other execution platforms and workflow systems as needed.
Hades is built upon the following key components:
-
API: Serving as the main entry point, the API handles all incoming job requests and provides status information.
-
Queue: Using NATS as a message queue, this component is responsible for managing the queue of jobs, ensuring efficient scheduling and reliable delivery.
-
Scheduler: The scheduler orchestrates the execution of jobs, coordinating with the executor components to run each job step in the appropriate environment.
-
Docker Executor: Designed for local development, the Docker executor is responsible for running jobs within Docker containers on a single host.
-
Hades Operator: The production-ready standard for Kubernetes. It implements a Kubernetes-native controller pattern using Custom Resource Definitions (CRDs). This mode offers superior scalability, automatic retries, and fine-grained RBAC integration.
-
-
Log Manager: Subscribes to job status and log events on NATS, aggregates per-job logs in memory, and exposes them through an HTTP API (
GET /jobs,/jobs/:id/logs,/jobs/:id/status, default port8081). It has its own Dockerfile and is deployed by the Helm chart (hades-log-manager). It is not part of thecompose.ymlstack, so run it locally withmake run. -
Dashboard: An optional, secured web UI served by the API itself (embedded SPA +
/api/*JSON/SSE endpoints). It shows queued/running/recently-completed jobs, a redacted job detail view, live logs, and system metrics, and updates live over Server-Sent Events. See Dashboard below.
Hades processes jobs through a sequence of well-defined steps:
- Job Submission: Jobs are submitted to the API, defining a series of steps to execute.
- Queuing: The job is queued in NATS for asynchronous processing.
- Scheduling: The scheduler picks up the job and schedules it on the appropriate executor.
- Execution: Each step of the job runs in its own container, with steps sharing data through a common volume.
- Completion: Upon completion, results are stored and made available through the API.
- Docker and Docker Compose for local development
- Kubectl and a Kubernetes cluster for production deployment
- Minikube for local Kubernetes testing (optional)
To run Hades in Docker mode for local development:
-
Clone the repository:
git clone https://github.com/Hades-Scheduler/hades.git cd hades -
Copy the
.env.examplefile to.env(the default configuration uses Docker as the executor, so no changes are necessary for local testing):cp .env.example .env
-
Start the Hades services:
-
All components in the CLI (NATS still runs in Docker):
make runThis launches
HadesAPI,HadesScheduler, andHadesLogManagerviago runand streams their logs to the terminal. PressCtrl-Cto stop them; runmake docker-stopto also shut NATS down. -
Full stack in Docker:
make docker-runUse
make docker-logsto follow the output andmake docker-stopto tear the stack down.
-
For production deployments, Hades is designed to run natively within a Kubernetes cluster using Helm. This is the recommended way to achieve full scalability and reliability.
-
Prerequisites:
- A Kubernetes cluster (v1.25+)
- Helm (v3.12+) installed locally.
-
Deployment: We provide a comprehensive Helm Chart that packages the API, Scheduler, Operator, and NATS broker. By default the scheduler runs in
operatormode, delegating job lifecycle management to the HadesOperator viaBuildJobcustom resources.Replace
<version>in the commands below with the chart version you want to install (for example1.0.0).# Install the published chart from GHCR (recommended) helm upgrade --install hades oci://ghcr.io/hades-scheduler/charts/hades \ --version <version> -n hades --create-namespace
Or install from a local checkout of this repository:
helm repo add nats https://nats-io.github.io/k8s/helm/charts helm dependency build ./helm/hades/ helm upgrade --install hades ./helm/hades -n hades --create-namespace
Note: Helm does not upgrade CRDs after the first install. When a release changes the
BuildJobCRD, apply it manually from the same chart version you installed (not from the mutablemainbranch):helm show crds oci://ghcr.io/hades-scheduler/charts/hades --version <version> | kubectl apply -f - -
Detailed Documentation: For advanced configuration (Ingress, TLS, resource limits) and step-by-step setup, please refer to the: Hades Helm Chart Guide
Here's an example of submitting a basic job to Hades:
{
"name": "Example Job",
"metadata": {
"GLOBAL": "test"
},
"steps": [
{
"id": 1,
"name": "Hello World",
"image": "alpine:latest",
"script": "echo 'Hello, Hades!'"
}
]
}Submit this job using:
curl -X POST -H "Content-Type: application/json" -d @job.json http://localhost:8080/buildTo have the Log Manager forward a job's aggregated logs somewhere (for example the
Artemis adapter), add an optional top-level callback_url to the request. It must
be an absolute http/https URL that includes a host. If omitted, the job's logs
are not forwarded.
{
"name": "Example Job",
"callback_url": "http://localhost:8082/adapter/logs",
"steps": [
{
"id": 1,
"name": "Hello World",
"image": "alpine:latest",
"script": "echo 'Hello, Hades!'"
}
]
}callback_url forwards logs. It is not a completion signal: its body is a bare
JSON array of log lines with no status, no exit code, and no timestamps, so a
receiver cannot tell a passing job from a failing one, and it is only sent after
the Log Manager has drained the job's JetStream log consumer (bounded by a 30 s
drain timeout that grows with log volume).
To be told that a job finished and how it ended, set the separate optional
top-level status_callback_url. Both fields are independent: a job may set
either, both, or neither, and existing callback_url behaviour is unchanged.
{
"name": "Example Job",
"callback_url": "http://localhost:8082/adapter/logs",
"status_callback_url": "http://localhost:8082/adapter/job-status",
"steps": [
{
"id": 1,
"name": "Hello World",
"image": "alpine:latest",
"script": "echo 'Hello, Hades!'"
}
]
}Hades POSTs a single JSON object as soon as the job reaches a terminal status
(Succeeded, Failed, or Stopped), without waiting for the log drain:
{
"event": "job.completed",
"job_id": "7f3a1c2b-...",
"name": "Example Job",
"status": "Failed",
"reason": "ImagePullBackOff: ...",
"queued_at": "2026-08-21T12:00:00Z",
"started_at": "2026-08-21T12:00:05Z",
"finished_at": "2026-08-21T12:00:41Z",
"duration_ms": 36000,
"attempt": 1
}Delivery is at-least-once: a failed delivery is retried with exponential
backoff and attempt counts up, so receivers must deduplicate on job_id. See
HadesLogManager/Readme.md for
the full schema, delivery guarantees, and configuration.
For more complex workflows, you can define multi-step jobs where each step runs in a different container:
{
"name": "Multi-Step Example",
"steps": [
{
"id": 1,
"name": "Step 1",
"image": "alpine:latest",
"script": "echo 'Setting up environment...' > /shared/output.txt"
},
{
"id": 2,
"name": "Step 2",
"image": "ubuntu:latest",
"script": "cat /shared/output.txt && echo 'Processing data...' >> /shared/output.txt"
},
{
"id": 3,
"name": "Step 3",
"image": "python:3.9-alpine",
"script": "cat /shared/output.txt && echo 'Finalizing...' >> /shared/output.txt && cat /shared/output.txt"
}
]
}Hades ships an optional, secured web dashboard served by HadesAPI itself.
The API embeds a React/TypeScript single-page app (Vite, Tailwind, shadcn/ui) and
exposes a small JSON + Server-Sent-Events API under /api; no separate service is
introduced. The dashboard shows queued/running/recently-completed jobs, a job
detail view (steps, scripts, resource limits, and metadata with secrets redacted),
live logs, and system metrics, all updating live.
- Job list, status, metrics, live updates come from the API's subscription to
the NATS
hades.jobstatus.*lifecycle events. The API now also publishesQueuedon enqueue so newly submitted jobs appear immediately. - Job detail is read on demand from the
HADES_JOBSJetStream KV bucket (the full submitted payload) and redacted before it leaves the process. - Logs stream live over SSE (
GET /api/jobs/:id/logs/stream): for a running job the API opens its own ephemeral JetStream consumer onhades.logs.<jobID>(full backlog + live tail) and pushes each new batch to the browser as it is produced - no polling. Completed jobs fall back to a one-shot snapshot proxied to the internalHadesLogManager(authenticated, so that service is never exposed directly).HadesLogManagerremains the aggregator for the Artemis-forwarding path.
Read-side state is in-memory and recent-only (bounded by DASHBOARD_JOB_RETENTION),
so both HadesAPI and HadesLogManager must stay at a single replica.
The dashboard is disabled unless configured (its /api routes return 503 and
the SPA is not served). Set three variables to enable it:
| Variable | Description |
|---|---|
DASHBOARD_USERNAME |
Login username |
DASHBOARD_PASSWORD_HASH |
bcrypt hash of the password (e.g. htpasswd -bnBC 12 "" 'yourpass' | tr -d ':\n') |
DASHBOARD_SESSION_SECRET |
Random string (>=32 chars) used to sign session cookies |
Optional: DASHBOARD_SESSION_TTL (default 12h), DASHBOARD_JOB_RETENTION
(default 1h), LOG_MANAGER_URL (default the in-cluster log manager),
SECRET_REDACT_MODE (smart default, or all), SECRET_KEY_PATTERNS,
DASHBOARD_TRUSTED_PROXIES, and DASHBOARD_COOKIE_INSECURE.
Login uses a signed, HttpOnly; Secure; SameSite=Strict session cookie; all
/api/* routes require a valid session, with rate-limited login lockout.
- Deploy behind TLS. The session cookie is
Secure, so login only works over HTTPS (orhttp://localhost). For a rare plain-HTTP dev setup, setDASHBOARD_COOKIE_INSECURE=true- never in production. - Set
DASHBOARD_TRUSTED_PROXIESto your ingress' address range when behind a reverse proxy. OtherwiseX-Forwarded-Foris ignored (the login lockout keys on the direct, un-spoofable address). - Responses carry
Content-Security-Policy,X-Frame-Options: DENY,X-Content-Type-Options: nosniff, and HSTS.
Job metadata (job- and step-level) is injected into containers as environment
variables and routinely carries credentials. The dashboard redacts it
server-side before any JSON is sent: values are masked when the key looks
sensitive (token, password, secret, ...) or the value itself looks like a
secret (credentials embedded in a URL, a PEM block, a JWT, a high-entropy token).
Step scripts are scanned with the same heuristics and have inline secrets
masked. Keys stay visible so operators can see which variables exist;
SECRET_REDACT_MODE=all masks every metadata value.
Residual exposure (by design): job logs are shown verbatim - streamed live
over SSE for running jobs and proxied as a snapshot for completed ones - so a secret a
job echoes to stdout will be visible (the log panel warns about this).
Script redaction is best-effort heuristic scrubbing, not a guarantee. Sessions are
stateless HMAC tokens, so logout and expiry are enforced by the cookie/TTL but a
leaked token cannot be revoked before it expires - keep DASHBOARD_SESSION_TTL
modest and rotate DASHBOARD_SESSION_SECRET to invalidate all sessions at once.
make ui-build # build the SPA into HadesAPI/web/dist (embedded by the API)
make run # run API + scheduler + log manager (+ NATS) with your dashboard env set
# then open http://localhost:8080/For SPA development with hot reload, run make ui-dev (Vite dev server on :5173,
proxying /api to the API). See HadesAPI/web/README.md.
In Kubernetes, set hadesApi.dashboard.secretName (a Secret with the three
DASHBOARD_* keys) in the Helm chart; the chart then wires the env and adds the
/ and /api ingress paths.
Hades is configured through environment variables (or a .env file for local runs). The most common settings:
| Variable | Description | Default |
|---|---|---|
HADES_EXECUTOR |
Execution platform: docker or k8s |
docker |
CONCURRENCY |
Number of jobs to process concurrently | 1 |
API_PORT |
Port for the Hades API | 8080 |
AUTH_KEY |
HTTP Basic Auth key for the API (empty = no auth) | `` |
NATS_URL |
NATS server URL | nats://localhost:4222 |
DEBUG |
Verbose (debug-level) logging | false |
OTEL_EXPORTER_OTLP_ENDPOINT |
OTLP endpoint for tracing (empty = tracing off) | `` |
See docs/configuration.md for the complete, per-component reference (Docker/Kubernetes executor, operator, and Log Manager options). A ready-to-copy .env.example lives at the repository root.
Hades instruments how much overhead it adds around each job, per step and per phase, splitting the wall-clock into overhead (Hades/Kubernetes coordination) and runtime (the user's container executing). Every service exposes Prometheus histograms (hades_phase_seconds, hades_job_overhead_seconds, …) on its /metrics port and logs a per-job job timing summary with overhead_pct. Setting OTEL_EXPORTER_OTLP_ENDPOINT additionally emits an OpenTelemetry trace per job - a waterfall across API → scheduler → operator. make run and make docker-run start a Jaeger UI on http://localhost:16686. See Overhead timing & tracing for the full phase taxonomy.
A top-level Makefile wraps the most common development tasks. Run make help to see every target.
| Target | Purpose |
|---|---|
make run |
Run HadesAPI, HadesScheduler, and HadesLogManager locally via go run (NATS auto-starts in Docker). |
make run-api / make run-scheduler / make run-logmanager / make run-operator |
Run a single component locally via go run. |
make docker-run / make docker-stop / make docker-logs |
Start, stop, or tail the full docker compose stack. |
make docker-run-api / make docker-run-scheduler / make docker-run-nats |
Start an individual service via docker compose. |
make build |
Compile every Go module in the workspace. |
make ui-build / make ui-dev / make ui-test |
Build, dev-serve, or test the dashboard SPA (HadesAPI/web). |
make docker-build |
Build all Hades container images. |
make test |
Run unit tests across every Go module. |
make test-race |
Same as make test with the race detector. |
make cover |
Generate and open the HadesAPI coverage report. |
make test-operator / make test-operator-e2e |
Run HadesOperator envtest unit tests, or Kind-based e2e tests. |
make fmt / make lint |
Format code with gofmt or run go vet. |
make docs-api |
Regenerate the OpenAPI specs for HadesAPI and HadesLogManager (run after changing a handler annotation or DTO). |
make docs-helm |
Regenerate the Helm chart values table from values.yaml comments (run after changing chart values). |
make vuln |
Run govulncheck (auto-installs it on first use). |
make deps-check / make deps-update / make deps-tidy |
List outdated direct dependencies, bump them, or run go mod tidy across all modules. |
make helm-deps |
Refresh the Helm chart subchart lock file. |
make ci |
Mirror the CI run locally (lint + test). |
Tests live alongside the code in each module, and CI (.github/workflows/ci.yml) runs the shared, HadesAPI, HadesScheduler, HadesLogManager, and HadesOperator suites (a build matrix) on every push and pull request.
The HadesOperator e2e target requires Kind to be installed locally.
For production deployments in a VM:
-
Ensure you have Docker installed in the VM
-
Copy the
.env.examplefile to.envand update the configuration:cp .env.example .env
-
Change the
LETSENCRYPT_EMAILvariable to your email address in your.envfile. -
Change the
HADES_API_HOSTvariable to domain name or your IP address in your.envfile. -
Create Traefik configuration files
touch traefik/acme.json chmod 600 traefik/acme.json
-
Deploy Hades:
docker compose -f compose.yml -f docker-compose.deploy.yml up -d
Hades includes Ansible playbooks for automated deployment.
See the ansible/hades/README.md file for more details.
Two manual workflows deploy the Helm chart to a Kubernetes cluster (one cluster, two namespaces):
Deploy to Kubernetes (prod)(.github/workflows/deploy-k8s-prod.yml) - deploys into namespacehadesfrom thek8s-prodenvironment. Theversioninput must belatestor a published GitHub Release tag; it is validated before anything touches the cluster. To deploy a release, run the workflow from that release's tag so the chart and CRDs match the released images.Deploy to Kubernetes (test)(.github/workflows/deploy-k8s-test.yml) - deploys into namespacehades-testfrom thek8s-testenvironment. Theversioninput accepts any image tag (latest, a release tag,pr-N, or a branch/sha tag). The chart and CRDs come from the ref the workflow runs from.
Both call the reusable deploy-k8s.yml, which writes the kubeconfig, creates the app
Secrets, applies the CRDs (server-side, since Helm never upgrades CRDs), and runs
helm upgrade --install --atomic. Per-environment, non-secret config lives in committed
values files (helm/hades/values-prod.yaml, helm/hades/values-test.yaml).
Each GitHub environment needs these secrets (add them once; the app Secrets are created on the first deploy and kept in sync afterwards):
| Secret | Purpose |
|---|---|
KUBE_CONFIG |
kubeconfig (plain YAML) for the target cluster |
AUTH_KEY |
protects the API /build endpoint (Secret hades-auth) |
DASHBOARD_USERNAME |
dashboard login user (Secret hades-dashboard) |
DASHBOARD_PASSWORD_HASH |
bcrypt hash of the dashboard password |
DASHBOARD_SESSION_SECRET |
dashboard session signing secret (>=32 chars) |
k8s-prod uses a required-reviewer protection rule, so a production deploy waits for
manual approval. TLS is handled in-cluster by cert-manager (the letsencrypt-prod
cluster-issuer), so no ACME secrets are needed in the workflow.
The chart is published to GHCR as an OCI artifact by
.github/workflows/release-chart.yml. It runs on pushes to main that touch
helm/** (or via workflow_dispatch) and publishes to
oci://ghcr.io/hades-scheduler/charts/hades.
Checklist when cutting a chart release:
- Bump
versioninhelm/hades/Chart.yaml(SemVer). This is the single source of truth: the workflow only publishes when the version does not already exist in GHCR, so a change tohelm/**without a version bump is a no-op. Never re-tag an already-published version - always bump. - Update
appVersionif the deployed application changed (it tracks the app, not the chart, and does not need to follow SemVer). - CRDs are not upgraded by Helm. Files under
helm/hades/crds/are only applied on first install and never onhelm upgrade. If a release changes theBuildJobCRD, bump the chart version, note it in the release, and tell users to apply the CRD from the matching chart version on existing clusters:helm show crds oci://ghcr.io/hades-scheduler/charts/hades --version <version> | kubectl apply -f -. - Subchart dependencies (currently
nats): if you change a dependency version inChart.yaml, runhelm dependency update helm/hadesand commit the updatedChart.lock. CI vendors the subchart at package time; thecharts/directory itself is not committed. - Validate before merging:
helm lint helm/hades,helm template helm/hades, and ideally a throwawayhelm installin a scratch namespace. - Package visibility: the GHCR chart package must be public for
anonymous
helm install(same as the container images). Set once in the org Packages settings after the first publish. - Keep the install snippet in sync: update the
--versionin the Kubernetes install instructions above when you cut a new version.
Hades uses Renovate (configured in renovate.json) to open automated PRs for dependency updates across Go modules, Helm charts, Docker base images, and GitHub Actions.
Prefer merging Renovate PRs whenever possible so lock files and changelog links stay consistent.
For manual checks (for example before cutting a release), the workspace is wired up through the top-level Makefile:
make deps-check # list outdated direct dependencies in every Go module
make deps-update # bump direct deps in every module and run go mod tidy
make helm-deps # refresh helm/hades/Chart.lock
make vuln # run govulncheck across every moduleAfter running make deps-update, verify the workspace still builds and tests pass:
make build
make testMajor-version upgrades (for example sigs.k8s.io/controller-runtime v0.22 -> v0.24, or any /v2, /v3 import path bump) often contain breaking API changes and should be reviewed one module at a time rather than via a blanket make deps-update.
Docker base images in the per-component Dockerfiles are tracked by Renovate; for a manual bump, look up the latest tag on the relevant registry and edit the FROM line.
┌─────────┐ ┌─────────┐ ┌───────────────┐
│ │ jobs │ │ jobs │ │
│ API │────────▶│ NATS │─────────▶│ Scheduler │
│ │ │ Queue │ │ │
└─────────┘ └────┬────┘ └───────┬───────┘
▲ │
status │ logs ▼
│ ┌──────────┴──────────┐
┌──────┴──────┐ │ │
│ │ ▼ ▼
│ Log │ ┌─────────────┐ ┌─────────────────┐
│ Manager │ │ Docker │ │ Kubernetes │
│ (HTTP API) │ │ Executor │ │ / Operator │
│ │ └─────────────┘ └─────────────────┘
└─────────────┘
- Special thanks to all contributors who have helped shape Hades
- Inspired by the need for a lightweight, scalable job execution system in educational environments
- Built with Go, Docker, Kubernetes, and NATS