Skip to main content

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:

  1. Java JDK 21 – Required for the Spring Boot application server. Gradle reads the target JDK from .java-version and uses its Java toolchain support. The checked-in wrapper downloads the pinned Gradle distribution and verifies server/gradle/wrapper/gradle-wrapper.properties' checksum. CI also validates the wrapper JAR. No global Gradle installation is needed.

  2. Docker and Docker Compose – Required for PostgreSQL and NATS. Verify both with docker info and docker compose version.

  3. 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 | bash

    On Windows: irm https://vite.plus/ps1 | iex. The launcher's own version does not matter: it runs the vite-plus this repository pins. Managed mode is on afterwards and provides the Node.js devEngines.runtime pins. Run vp env off if you manage Node.js yourself. vp install then installs dependencies through the pinned pnpm, which Vite+ downloads itself: no global pnpm, no Corepack.

    The installer adds vp to your login shell. Anything that does not start from one, an IDE task or a desktop app, needs vp on its PATH like node and git.

    After vp install, vp --version prints the pinned vite-plus.

  4. Helm CLI – Required by vp run check and the pre-push hook: the tooling tests render the chart with the real helm on your PATH. Install the version that .github/actions/setup-helm/action.yml pins. That file is the one home of the version and its checksums. CI installs the same version. Verify it with helm version.

  5. 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.

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.

Keep it local

Never commit application-local.yml. It may contain secrets and machine-specific configuration.

Running the stack​

  1. Check that the Docker daemon runs with docker info.

  2. From the repo root, start everything with one command:

    vp run dev

    This launches an mprocs session 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 Boot
    vp run dev:webapp # terminal 2 — Vite dev server
  3. Open the configured server and webapp ports. See the port map. The defaults are http://localhost:8080 and http://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 (and SPRINGDOC_SWAGGER_UI_ENABLED=true for the browser UI) and Swagger UI appears at http://localhost:8080/swagger-ui/index.html. Committing a spec does not need either flag — vp run generate:api:specs boots under the specs profile, 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​

ServiceDefaultVariableConfig location
PostgreSQL5432POSTGRES_PORTserver/.env
Application server8080SERVER_PORTserver/.env
Sandbox gateway8081SANDBOX_API_PORTserver/.env
Mailpit SMTP1025MAILPIT_SMTP_PORTserver/.env
Mailpit UI8025MAILPIT_UI_PORTserver/.env
Webapp (Vite dev)4200WEBAPP_PORTShell env only
Pre-flight check

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" };
Keep it local

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_PORTAPPLICATION_HOST_URL in .envCORS origin must match
SERVER_PORTWebapp's APPLICATION_SERVER_URL (if connecting directly)API base URL changes
POSTGRES_PORTNothing (automatic)Both Compose and Spring read from .env

GitHub configuration​

Hephaestus supports two GitHub authentication modes. Pick the one that fits your workflow:

ModeBest forWorkspace creationRepository monitors
PATQuick local developmentManual (from config)Manual (you list them)
GitHub AppTesting webhooks, productionAutomatic (from installations)Automatic (from installation events)

Option A: Personal Access Token (simpler)​

  1. Create a Personal Access Token with scopes: repo, read:org, read:user.

  2. Create application-local.yml:

    hephaestus:
    workspace:
    init-default: true
    default:
    login: your-github-org # e.g., "ls1intum" or "HephaestusTest"
    token: ghp_your_token
    repositories-to-monitor:
    - your-github-org/repo1
    - your-github-org/repo2
  3. Leave hephaestus.integration.github.app.id unset or set to 0.

Option B: GitHub App​

  1. Create a GitHub App with appropriate permissions.

  2. Create application-local.yml:

    hephaestus:
    integration:
    github:
    app:
    id: 12345
    privateKey: |
    -----BEGIN RSA PRIVATE KEY-----
    ...
    -----END RSA PRIVATE KEY-----
    workspace:
    init-default: false # Workspaces created from installations
  3. 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.

Tip

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​

  1. Create a GitHub OAuth application with the callback URL http://localhost:8080/login/oauth2/code/github (replace 8080 with your SERVER_PORT). The local auth issuer defaults to http://localhost:${SERVER_PORT:8080}. If you set HEPHAESTUS_AUTH_ISSUER, register its origin instead. Add HEPHAESTUS_AUTH_API_BASE_PATH before /login if you set an API prefix.

  2. Copy server/.env.example to server/.env and 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>
  3. (Optional) To enable local GitLab login, register an OAuth application on your GitLab instance. Use callback http://localhost:<SERVER_PORT>/login/oauth2/code/gitlab and scope read_user. Apply the same issuer and API-prefix settings as for GitHub. Set GITLAB_OAUTH_CLIENT_ID / GITLAB_OAUTH_CLIENT_SECRET. Point GITLAB_OAUTH_BASE_URL at the instance. Its default is https://gitlab.com, but you can use, for example, https://gitlab.lrz.de. To hide the GitLab button, leave the client id blank.

  4. 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/SECRET are set. Check that the provider callback matches HEPHAESTUS_AUTH_ISSUER, or http://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) in server/.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:

  1. It creates the k3d cluster hephaestus with two nodes and publishes HTTPS on the loopback address only. The Kubernetes API uses a free port on 127.0.0.1. An older cluster with a non-loopback API binding requires vp 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 of ghcr.io.
  2. It builds the server, webapp, agent and PostgreSQL images, and pushes them to that registry.
  3. It creates the namespaces, a Secret with generated values, and the certificates. It keeps them in the folder kubernetes-cluster of the gitignored .context folder.
  4. It turns on the Gateway provider of the Traefik that k3s ships, and creates the Gateway cluster.
  5. It installs the chart with the dev profile and two workers. It waits until each worker has passed its isolation probe.
  6. 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.

CommandDoes
vp run dev:kubernetes:downStops the cluster and keeps its data. The next dev:kubernetes starts it again. It fails if no cluster exists.
vp run dev:kubernetes:resetDeletes 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)​

  1. Install dependencies:

    vp install
  2. Start Vite (defaults to port 4200):

    vp run dev:webapp
  3. Visit http://localhost:4200 (or your custom WEBAPP_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 DTO suffix. 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.