Local Development
Hephaestus supports full-stack development across Java and TypeScript. This guide focuses on the development environment. Production operations are covered in the Admin guide, starting with Kubernetes.
Prerequisites
Install and configure the following tools before you attempt a local build:
-
Java JDK 21 – Required for the Spring Boot application server. Gradle reads the target JDK from
.java-versionand uses its Java toolchain support. The checked-in wrapper downloads the pinned Gradle distribution and verifiesserver/gradle/wrapper/gradle-wrapper.properties' checksum. CI also validates the wrapper JAR. No global Gradle installation is needed. -
Docker and Docker Compose – Required for PostgreSQL and NATS. Verify both with
docker infoanddocker compose version. -
Vite+ (
vp) – The command surface for every repository task, and how you get the pinned Node.js. Install the launcher:curl -fsSL https://vite.plus | bashOn Windows:
irm https://vite.plus/ps1 | iex. The launcher's own version does not matter: it runs thevite-plusthis repository pins. Managed mode is on afterwards and provides the Node.jsdevEngines.runtimepins. Runvp env offif you manage Node.js yourself.vp installthen installs dependencies through the pinned pnpm, which Vite+ downloads itself: no global pnpm, no Corepack.The installer adds
vpto your login shell. Anything that does not start from one, an IDE task or a desktop app, needsvpon its PATH likenodeandgit.After
vp install,vp --versionprints the pinnedvite-plus. -
Helm CLI – Required by
vp run checkand the pre-push hook: the tooling tests render the chart with the realhelmon your PATH. Install the version that.github/actions/setup-helm/action.ymlpins. That file is the one home of the version and its checksums. CI installs the same version. Verify it withhelm version. -
NATS CLI (optional) – Helpful when inspecting the webhook/sync event stream (NATS is disabled by default locally). The agent job queue runs on PostgreSQL and needs no NATS.
If you push branches to the repository instead of a fork, also configure commit signing.
CONTRIBUTING.md § Signed Commits has the setup.
vp install warns while it is missing.
Recommended IDE setup
Open the repository using the project.code-workspace file in VS Code and install the workspace recommendations (@recommended in the Extensions view). Key extensions include:
- Java Extension Pack
- Spring Boot tools
- TypeScript tooling
- oxlint (lint) and oxfmt (format)
- Tailwind CSS IntelliSense
The Oxc extension provides oxlint diagnostics. Workspace settings use it to format JavaScript, TypeScript, and JSON files on save.
Run the same checks from the shell with:
vp run check:agents # Format check, lint, and both typechecks outside the webapp
vp run fix:agents # check:agents, applying every safe fix
vp run check:webapp # The SPA's format and lint checks; the lint is type-aware and type-checks
vp run check # Complete local quality gate
Use the local verification contract for affected checks, the pre-push gate, and CI exclusions.
IntelliJ and WebStorm are also supported.
Java formatting
vp run format:java
vp run format:java:check
The repository commands are the source of truth. Optional editor integrations:
Other editors should run the repository command. VS Code does not format Java on save: a Java formatter with a different style would undo Spotless's Palantir formatting.
Application server
Spring profiles and local configuration
For development stick to the local profile, which is the default. The other shipped profiles each
have their own application-<name>.yml under server/application/src/main/resources/ — prod and specs, plus
the per-role and per-purpose overlays (worker, webhook, e2e, cds-training) documented where
they are used.
These are Spring profiles, not build profiles. Gradle’s bootRun passes -Pprofiles=… through to
Spring. vp run dev:server:e2e uses that interface.
Create application-local.yml in server/application/src/main/resources to override defaults. This file is gitignored.
Never commit application-local.yml. It may contain secrets and machine-specific configuration.
Running the stack
-
Check that the Docker daemon runs with
docker info. -
From the repo root, start everything with one command:
vp run devThis launches an
mprocssession with the server and webapp in separate panes (switch with the arrow keys). The PostgreSQL container is brought up automatically.Prefer plain terminals? Run the two sides yourself:
vp run dev:server # terminal 1 — brings up Postgres, then Spring Bootvp run dev:webapp # terminal 2 — Vite dev server -
Open the configured server and webapp ports. See the port map. The defaults are
http://localhost:8080andhttp://localhost:4200.The OpenAPI description and Swagger UI are off by default. When enabled, these endpoints are unauthenticated and expose the route list, including admin routes. For a local session, set
SPRINGDOC_API_DOCS_ENABLED=true(andSPRINGDOC_SWAGGER_UI_ENABLED=truefor the browser UI) and Swagger UI appears athttp://localhost:8080/swagger-ui/index.html. Committing a spec does not need either flag —vp run generate:api:specsboots under thespecsprofile, which turns them on itself.
To wipe the local database (e.g. after a bad migration or sync), run vp run dev:reset. The Postgres data is a bind-mounted folder (server/postgres-data), so docker compose down -v alone does not clear it — dev:reset removes the folder and recreates the stack.
Port overrides
Every host-side port is configurable so multiple Hephaestus instances (or other services) can coexist on the same machine. Container-internal ports never change – only the localhost binding moves.
Default port map
| Service | Default | Variable | Config location |
|---|---|---|---|
| PostgreSQL | 5432 | POSTGRES_PORT | server/.env |
| Application server | 8080 | SERVER_PORT | server/.env |
| Sandbox gateway | 8081 | SANDBOX_API_PORT | server/.env |
| Mailpit SMTP | 1025 | MAILPIT_SMTP_PORT | server/.env |
| Mailpit UI | 8025 | MAILPIT_UI_PORT | server/.env |
| Webapp (Vite dev) | 4200 | WEBAPP_PORT | Shell env only |
Run vp run check:ports before starting the stack. It checks the six ports above. A NATS
container binds 4222 as well, and a conflict there shows up only at boot.
Email
The local profile sends every email to the Mailpit container from server/compose.yaml. Nothing
leaves your machine. Its SMTP and inbox ports bind to 127.0.0.1 only. Open http://localhost:8025 (MAILPIT_UI_PORT) to read
what the server sent, with the HTML part, the plain-text alternative and the headers side by side.
The quickest end-to-end check is the Send test email button on the instance settings page
(/admin/settings). It goes through the same gateway as every notification, including silent mode.
Templates live in server/application/src/main/resources/templates/email/ — html/ and text/
hold the two parts of one message, and messages.properties holds the subjects.
How it works
The .env file in server/ is read by both Docker Compose (for container port mappings) and Spring Boot (through spring.config.import). The webapp port is a separate shell setting, as listed above.
Ports flow through the system like this:
.env (POSTGRES_PORT=15432)
├─► Docker Compose → '15432:5432' host mapping
└─► Spring Boot → jdbc:postgresql://localhost:15432/hephaestus
Changing ports
Basic example – move PostgreSQL to a non-standard port:
# server/.env
POSTGRES_PORT=15432
The application server automatically picks up the new port from the same .env file.
Changing the application server port:
# server/.env
SERVER_PORT=18080
If the webapp also needs to reach the server at the new port, edit the runtime-environment stub the webapp loads before its bundle:
// webapp/public/env-config.js
window.__ENV__ = { APPLICATION_SERVER_URL: "http://localhost:18080" };
env-config.js is committed as an empty stub and overwritten by the Docker entrypoint in
production. Treat a local override the way you treat application-local.yml: do not commit it.
The webapp has no Vite-level environment variables — vite.config.ts does not call loadEnv, sets
no envPrefix, and nothing in src/ reads import.meta.env beyond DEV. A webapp/.env file has
no effect on the application's configuration, and there is no VITE_-prefixed setting for the server
URL.
Changing the webapp port:
The Vite dev server reads WEBAPP_PORT from the shell environment (process.env, not a .env file
and not server/.env):
WEBAPP_PORT=5200 vp run dev:webapp
When changing the webapp port, the server's CORS origin must also be updated so API calls are not rejected:
# server/.env
APPLICATION_HOST_URL=http://localhost:5200
Cascading effects reference
Changing a port in one place may require updates elsewhere. This table shows the full picture:
| When you change... | Also update... | Why |
|---|---|---|
WEBAPP_PORT | APPLICATION_HOST_URL in .env | CORS origin must match |
SERVER_PORT | Webapp's APPLICATION_SERVER_URL (if connecting directly) | API base URL changes |
POSTGRES_PORT | Nothing (automatic) | Both Compose and Spring read from .env |
GitHub configuration
Hephaestus supports two GitHub authentication modes. Pick the one that fits your workflow:
| Mode | Best for | Workspace creation | Repository monitors |
|---|---|---|---|
| PAT | Quick local development | Manual (from config) | Manual (you list them) |
| GitHub App | Testing webhooks, production | Automatic (from installations) | Automatic (from installation events) |
Option A: Personal Access Token (simpler)
-
Create a Personal Access Token with scopes:
repo,read:org,read:user. -
Create
application-local.yml:hephaestus:workspace:init-default: truedefault:login: your-github-org # e.g., "ls1intum" or "HephaestusTest"token: ghp_your_tokenrepositories-to-monitor:- your-github-org/repo1- your-github-org/repo2 -
Leave
hephaestus.integration.github.app.idunset or set to0.
Option B: GitHub App
-
Create a GitHub App with appropriate permissions.
-
Create
application-local.yml:hephaestus:integration:github:app:id: 12345privateKey: |-----BEGIN RSA PRIVATE KEY-----...-----END RSA PRIVATE KEY-----workspace:init-default: false # Workspaces created from installations -
Install the app on your organization. Hephaestus creates workspaces and monitors automatically.
Limiting sync scope (development filters)
When using GitHub App mode, your app might have access to hundreds of repositories. Use filters to focus on specific orgs/repos during development:
hephaestus:
sync:
filters:
allowed-organizations:
- hephaestus-build
- ls1intum
- HephaestusTest
allowed-repositories:
- hephaestus-build/Hephaestus
- ls1intum/Artemis
- HephaestusTest/demo-repository
Empty lists = no filtering (production behavior). Non-empty = only sync matching items.
Filters do not delete data – they just skip sync operations. Workspace and organization metadata is still created for all installations.
AI model (local)
Practice reviews and the mentor need at least one enabled model. Providers are registered at runtime — either an instance-wide catalog entry (Instance admin → AI models) or a workspace's own "bring your own AI provider" connection. Configure it through the same admin UI/API used in production. There is deliberately no second env-var catalog or startup seeder that can drift from the stored configuration.
New connections and models start inactive. Test the connection.
Declare its price or explicitly mark it as having no metered API cost.
Activate it.
Bind it to a purpose (PRACTICE_REVIEW or MENTOR) on the workspace's AI models page.
For an automated local end-to-end setup, use vp run dev:e2e:setup. It creates the connection, model, and agent binding through the application API.
It reads provider credentials from E2E_* environment variables.
Thus, they stay outside source control and command-line history.
Models always use the in-app LLM proxy: the upstream key is injected by the proxy and never reaches the sandbox.
An SSRF egress guard (EgressPolicy) validates provider base URLs.
In production, it accepts only a public HTTPS host, or a private host that the operator lists in hephaestus.llm.egress.private-hosts.
It rejects plain-HTTP localhost/127.0.0.1/::1 targets. To point a connection at a local OpenAI-compatible gateway during development,
set:
# application-local.yml
hephaestus.llm.egress.allow-loopback: true
or HEPHAESTUS_LLM_EGRESS_ALLOW_LOOPBACK=true in server/.env. It is off by default and
application-e2e.yml is the only shipped profile that turns it on. Never enable it in a deployed
environment.
Authentication (local)
Authentication is Hephaestus-native (no Keycloak — see ADR 0017). The server federates to GitHub (and optionally GitLab — gitlab.com or a self-hosted instance) through Spring Security oauth2Login, then issues its own short-lived ES256 cookie-session JWT. The webapp reads GET /user and redirects to /auth/login — there is no token in the browser.
Configure GitHub login
-
Create a GitHub OAuth application with the callback URL
http://localhost:8080/login/oauth2/code/github(replace8080with yourSERVER_PORT). The local auth issuer defaults tohttp://localhost:${SERVER_PORT:8080}. If you setHEPHAESTUS_AUTH_ISSUER, register its origin instead. AddHEPHAESTUS_AUTH_API_BASE_PATHbefore/loginif you set an API prefix. -
Copy
server/.env.exampletoserver/.envand set the credentials:cp server/.env.example server/.env# Edit .env and set:# GITHUB_OAUTH_CLIENT_ID=<github-client-id># GITHUB_OAUTH_CLIENT_SECRET=<github-client-secret> -
(Optional) To enable local GitLab login, register an OAuth application on your GitLab instance. Use callback
http://localhost:<SERVER_PORT>/login/oauth2/code/gitlaband scoperead_user. Apply the same issuer and API-prefix settings as for GitHub. SetGITLAB_OAUTH_CLIENT_ID/GITLAB_OAUTH_CLIENT_SECRET. PointGITLAB_OAUTH_BASE_URLat the instance. Its default ishttps://gitlab.com, but you can use, for example,https://gitlab.lrz.de. To hide the GitLab button, leave the client id blank. -
Run the application server with
vp run dev:server. It brings up the Compose services, installs the generated-clients module and starts the application module. Open the webapp and click Continue with GitHub.
GitLab workspaces are created only on GITLAB_DEFAULT_SERVER_URL, falling back to
GITLAB_OAUTH_BASE_URL. Set it consistently on the server and workers. Additional GitLab login
providers permit sign-in and identity linking. They do not permit syncing another GitLab instance.
Super admin
App-level admin is the APP_ADMIN role on the account table, with authority app_admin in the JWT.
admin is the per-workspace role name, which instance tokens exclude.
An existing admin manages this role from the /admin/users UI.
Before first boot, list the first admin in HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS, for example github:@you.
Promotion runs on their first login.
It is idempotent and promote-only.
Manual database edits are a last resort.
Troubleshooting auth
- 401 after login: check that
GITHUB_OAUTH_CLIENT_ID/SECRETare set. Check that the provider callback matchesHEPHAESTUS_AUTH_ISSUER, orhttp://localhost:<SERVER_PORT>when it is unset, plus the configured API prefix. - Logins do not survive a server restart locally: set
HEPHAESTUS_AUTH_STATE_COOKIE_KEY(base64 32-byte.openssl rand -base64 32) inserver/.env— otherwise an ephemeral per-boot key is used. - Reset everything:
vp run dev:reset(see Running the stack). On next login the account and identity link are recreated.
Kubernetes
vp run dev:kubernetes installs the chart on a local k3d cluster with the images of this checkout.
It needs Docker with at least 10 GiB of memory, k3d, kubectl, Helm 4, the pack CLI, openssl and curl.
The cluster uses about 6 GiB, and a practice review 3 GiB more.
The command checks these first and names each missing item.
On macOS, brew install k3d helm kubectl installs three of the tools.
If the buildpacks/tap/pack formula fails, use the pack release archive.
The native stack (vp run dev) is the default inner loop.
It reloads the webapp at once.
Your IDE starts and debugs the JVM.
It has no NetworkPolicy, no Jobs, no Gateway and no Kubernetes sandbox backend.
Use the cluster before you push a change to one of these:
- The Helm chart (
deploy/chart/) or its script (scripts/kubernetes-cluster.ts). - The Kubernetes sandbox backend or the agent gateway of the worker.
- A runtime role, or a setting that a role binds at start.
- The agent or PostgreSQL image.
CI runs the cluster jobs for these paths, and vp run check:affected reminds you of them.
A cold vp run dev:kubernetes took about 5 minutes on an Apple silicon Mac, and a warm run about 3.
Debug a defect in the native stack when you can.
In the cluster, follow the logs of a role with the kubectl command that dev:kubernetes prints.
vp run dev:kubernetes does these steps:
- It creates the k3d cluster
hephaestuswith two nodes and publishes HTTPS on the loopback address only. The Kubernetes API uses a free port on127.0.0.1. An older cluster with a non-loopback API binding requiresvp run dev:kubernetes:reset. Port 443 must be free. The check stops the command when a program holds it. It also creates a local registry, which the nodes use as a mirror ofghcr.io. - It builds the server, webapp, agent and PostgreSQL images, and pushes them to that registry.
- It creates the namespaces, a Secret with generated values, and the certificates.
It keeps them in the folder
kubernetes-clusterof the gitignored.contextfolder. - It turns on the Gateway provider of the Traefik that k3s ships, and creates the Gateway
cluster. - It installs the chart with the dev profile and two workers. It waits until each worker has passed its isolation probe.
- It sends a request to the webapp and the API through the Gateway.
Then it prints the URL,
https://hephaestus.localhost, and how to sign in.
The local CA ca.crt in the kubernetes-cluster work folder signed the certificate.
The command prints its path and the commands that make your system trust it.
Below, $WORK is the work folder. On macOS:
security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db "$WORK/ca.crt"
On Debian or Ubuntu:
sudo cp "$WORK/ca.crt" /usr/local/share/ca-certificates/hephaestus-dev.crt
sudo update-ca-certificates
Firefox, and Chrome on Linux, keep their own stores.
If you use mkcert, CAROOT="$WORK/mkcert" mkcert -install adds the CA to those stores too.
A certificate lasts 397 days. A run issues it again when fewer than 30 days remain.
The chart runs the prod profile, so the passwordless dev sign-in is off.
To sign in, create a GitHub OAuth app for the cluster.
Use the homepage https://hephaestus.localhost and the callback https://hephaestus.localhost/api/login/oauth2/code/github.
Then give its values to the command:
GITHUB_OAUTH_CLIENT_ID=... GITHUB_OAUTH_CLIENT_SECRET=... \
HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS=github:@you vp run dev:kubernetes
A second run reuses the cluster, the registry, the Secrets and the database.
It keeps every value of the Secrets, and adds only a key that a newer checkout needs.
It also keeps the OAuth app, so you give the variables only once.
Give both again to change the app, or HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS alone to change the first admins.
vp run dev:kubernetes:reset removes the app with the cluster.
Every kubectl and helm call of the command uses the cluster k3d-hephaestus, whatever your current context is.
| Command | Does |
|---|---|
vp run dev:kubernetes:down | Stops the cluster and keeps its data. The next dev:kubernetes starts it again. It fails if no cluster exists. |
vp run dev:kubernetes:reset | Deletes the cluster, its data, the registry and the work folder with the CA. It prints what it removed. It succeeds if no cluster exists. The next run makes a new CA, so trust it again. |
If Docker has no free address pool for the cluster network, the command stops before k3d starts.
Stopped Compose stacks of other worktrees keep their networks.
The command then tells you to run docker network prune, which removes the networks that no container uses.
The CI cluster jobs run the same script (scripts/kubernetes-cluster.ts) with the images of their run.
The k3d job also serves the release through the same Gateway, and sends the same HTTPS requests.
Webhook receiver
The webhook receiver lives in the Java server (integration.core.webhook package). In local
development the monolith default boots all three runtime roles (server, worker, webhook),
so vp run dev:server exposes the unified /webhooks/{kind} endpoint (kinds: github,
gitlab, slack, outline) alongside the rest of the API.
Production runs the receiver in its own container — Runtime roles and ADR 0008.
Set the webhook secret before the first start, so that signature checks succeed.
Auto-registration and the receiver use the same secret.
In the native stack, add it to server/.env:
echo "WEBHOOK_SECRET=$(openssl rand -hex 32)" >> server/.env
vp run dev:kubernetes generates it in the release Secret.
The value must match the secret configured on your GitHub and GitLab webhooks (validated through
X-Hub-Signature-256 for GitHub. X-Gitlab-Token, or webhook-signature for a hook with a signing
token, for GitLab — see WEBHOOK_SECRET).
For Slack app setup, use the scope/event checklist in
docs/admin/production-setup.mdx and the manifest template in
docs/admin/slack-app-manifest-template.yml.
Web client (webapp)
-
Install dependencies:
vp install -
Start Vite (defaults to port 4200):
vp run dev:webapp -
Visit
http://localhost:4200(or your customWEBAPP_PORT).
UI foundations
- We ship Tailwind CSS 4 in JIT mode. Keep utility classes in JSX and rely on design tokens defined in
src/styles/theme-tokens.css. - Enable Tailwind IntelliSense in your IDE. Typeahead prevents typos and invalid compositions.
- Avoid premature abstractions with
@apply. Duplication is fine when it keeps components readable.
Component workflow (Storybook + Chromatic)
Storybook is the source of truth for presentational components.
vp run --filter webapp storybook:dev # Local playground on http://localhost:6006
vp run --filter webapp build-storybook # Static bundle for Chromatic
vp run --filter webapp chromatic:ci # Visual regression in CI
- Every new UI component ships with at least one story covering empty, loading, and error states.
- CI runs Chromatic for same-repository pull requests that change the webapp – review visual diffs before merging.
- Reference ui.shadcn.com for composition patterns. A new widget builds on the Base UI primitives already wrapped under
src/components/ui/**.
OpenAPI client
webapp/src/api/** is generated from server/openapi.yaml and is the only way the webapp talks to
the server. After an API change, from the repository root:
vp run generate:api # regenerates server/openapi.yaml from the packaged JAR, then the client
vp run generate:api:client regenerates the clients alone from the committed
spec: this one and the Chrome extension's extension/src/api/**. Commit the spec and both clients
with the change.
CI fails a change that breaks a client of the previous release.
See API compatibility.
Both clients use the generator in scripts/api-generator/.
That workspace supplies the TypeScript 6 compiler API required by the stable generator.
The applications and root tooling keep TypeScript 7.
The generator pin lives in scripts/api-generator/package.json.
- Schema names carry no
DTOsuffix. The server strips it when it publishes the spec. - Pair every new endpoint with its generated TanStack Query options. Never hand-roll
fetch— the generated client is configured once and carries the cookie session.
Updating Java dependencies
Edit server/gradle/libs.versions.toml for library and plugin versions.
Edit server/build.gradle.kts for plugin-classpath BOMs and transitive constraints.
Then resolve the graphs and exercise the changed tools.
Strict dependency locking rejects missing lock state as well as version drift. Do not delete a
lockfile or disable verification to make an update pass.
The root dependencies report covers both projects, including PMD's auxiliary classpath.
The buildEnvironment report covers the separately locked plugin classpath in
server/buildscript-gradle.lockfile. Renovate's standard lock and checksum commands refresh the
same graphs used by source builds.
Set GRADLE_USER_HOME to an empty temporary directory and disable configuration caching when
generating metadata, so cached resolution cannot omit parent POM or module-descriptor checksums:
./server/gradlew -p server --no-configuration-cache \
dependencies buildEnvironment \
--write-locks --write-verification-metadata sha256
./server/gradlew -p server --no-configuration-cache \
:application:check :application:bootJar --write-locks --write-verification-metadata sha256
Review the dependency-lock diffs and independently verify newly trusted artifacts against their
publisher before committing server/gradle/verification-metadata.xml. Metadata generation records
what was downloaded. It does not establish trust. Run the checks again without metadata-writing
flags, still without configuration caching and with another empty Gradle user home, to verify fresh
downloads. Exercise changed generators, formatters, Liquibase or mutation tooling as well: their
classpaths are separate from the application runtime. Restore your usual Gradle user home afterward.
Gradle does not remove the verification entry of a version that no build downloads any more. OSV Scanner reports such an entry as a dependency — Gradle dependency inventory. After an update that replaces versions, run:
node scripts/check-gradle-verification-metadata.ts --write
It resolves every server dependency in an empty Gradle home with strict verification.
Then it deletes each component that the resolution did not download.
The deletion adds no checksum to trust.
If a CI task reads a reported component, add that task to the script instead of deleting the entry.
Without --write, the command fails on such a component, as Quality → App Server does on a pull request.
Run --write on Linux, as CI does.
Another operating system can resolve a different set of components.
The script compares whole components.
It does not report an unused artifact inside a component that the build still downloads, such as the native JAR of another platform.
For a Gradle wrapper update, also run the build with --warning-mode all and review plugin
compatibility before accepting a new major. Do not suppress upstream deprecations to make the
upgrade appear clean. A JDK-line change updates .java-version and Renovate's matching
constraints.java. gate:toolchain rejects drift between them.