Skip to content

Local development

Get a gateway running on your machine, with or without an identity provider.

Prerequisites

  • JDK 25 (Temurin; GraalVM CE 25 only for native builds)
  • A container runtime — Testcontainers and the Arconia dev services for the build, Compose for running. Docker works out of the box; Podman works too, with the setup in Running the gates on Podman
  • git

Node and pnpm are provisioned by the Maven build. You do not need them to build or run the gateway; they matter only for UI development loops.

Run with Compose

The quickest path. Starts the gateway and PostgreSQL on port 8080:

$ docker compose up

Run from source

The development loop. Nothing to configure — the PostgreSQL dev service starts with the application, so none of the datasource variables below are needed:

$ ./mvnw spring-boot:run

The portal is built into target/classes/static before the application starts, so this serves the portal at / as well as the API.

To run what actually ships instead:

$ ./mvnw verify
$ java -jar target/skills-gateway-server-*.jar

The build packages the React portal into the jar, so the running application serves the portal at /.

Why the dev services are not in the jar

The Arconia dev services and the OpenTelemetry starter are declared <optional>true</optional>. Spring Boot's repackage leaves optional dependencies out of the jar, and the native profile pins them to test scope, so they reach neither the jar, the container image nor the native binary — while spring-boot:run still sees them. Maven has no environment-oriented scope, so this is the closest equivalent to Gradle's testAndDevelopmentOnly.

Database

The datasource is supplied entirely by environment — there is nothing in application.yaml:

$ export SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/skillsgateway
$ export SPRING_DATASOURCE_USERNAME=skillsgateway
$ export SPRING_DATASOURCE_PASSWORD=skillsgateway

Flyway builds the schema from a single consolidated migration on startup. There is nothing to configure and nothing to run by hand.

Authentication

With an identity provider

Point the OIDC variables at your IdP:

$ export SGW_OIDC_CLIENT_ID=skills-gateway
$ export SGW_OIDC_CLIENT_SECRET=...
$ export SGW_OIDC_AUTHORIZATION_URI=https://idp.example.com/authorize
$ export SGW_OIDC_TOKEN_URI=https://idp.example.com/token
$ export SGW_OIDC_JWK_SET_URI=https://idp.example.com/jwks

The client registration id is idp and must exist at build time for native-image, which is why the defaults are the placeholders change-me and idp.invalid. A gateway started without these will not complete a login.

Without one — the escape hatch

$ ./mvnw spring-boot:run -Dspring-boot.run.arguments=--skills-gateway.dev-insecure-auth=true
$ java -jar target/skills-gateway-server-*.jar --skills-gateway.dev-insecure-auth=true

Never outside a development loop

This makes the entire web surface unauthenticated — all of /api/**, /actuator/** and /docs — and injects a synthetic principal dev, which is then what appears in every audit entry. The application logs a loud warning at startup.

It does not affect the git facade: /git/** still requires a valid personal access token.

It refuses to start once an identity provider is configured

The escape hatch is for a loop that has no identity provider. Set it alongside a configured one — a real SGW_OIDC_CLIENT_ID, an SGW_OIDC_* endpoint pointing anywhere other than idp.invalid, or a pinned skills-gateway.oidc.issuer — and the gateway refuses to start, naming what tripped it. Leave the OIDC variables unset and the placeholders shipped in application.yaml keep the local loop working. The full rule is in Configuration.

Verify it is up

/actuator/health is the only unauthenticated endpoint:

$ curl -s localhost:8080/actuator/health
{"status":"UP"}

Then open http://localhost:8080/ and log in.

Probe the bare path

Use /actuator/health exactly. Health subpaths such as /actuator/health/liveness are not permitted by the security chain and redirect to the identity provider — which is why the Kubernetes manifests point both probes at the bare path.

Storage

Git repositories live under skills-gateway.data-dir, which defaults to data relative to the working directory. In the container image it is /data.

Storage durability is a decision, and the chart makes you state it

The chart has no default persistence.mode. ephemeral is an emptyDir and loses the quarantine, published and hosted repositories on every pod restart; existingClaim binds a durable volume; none keeps no volume at all and is accepted only on the object-store backend. See Choosing and migrating the storage backend.

Running against object storage

The default is the filesystem backend, which needs nothing. To develop against the object-store backend instead, start the Compose profile that brings up a local S3-compatible store — Floci, the same emulator the build's object-store suites run against:

$ SGW_STORAGE_BACKEND=object-store docker compose --profile object-store up

The bucket must exist before the gateway starts; create skills-gateway once with any S3 client pointed at http://localhost:4566. The gateway probes the bucket at startup and refuses to run if conditional writes are not honoured, so a store that cannot serialize reference transitions is a failed start rather than a corruption found later.

The object-store suites in ./mvnw verify do not use this Compose service at all — they take their store from the Arconia Floci dev service, so one container serves both bootRun through the dev service and the test suite.

Observability

Traces, metrics and logs can be sent to a local Grafana LGTM stack — the grafana/otel-lgtm image, which bundles an OpenTelemetry Collector with Loki (logs), Tempo (traces), Prometheus (metrics) and Grafana. It is provisioned as an Arconia dev service, so there is nothing to install and no compose file to start.

It is opt-in through the observability Spring profile and off in every other run: ./mvnw clean verify and the e2e suite start no LGTM container and attempt no OTLP export, which keeps the test loop fast and the logs quiet.

$ ./mvnw spring-boot:run -Dspring-boot.run.profiles=observability

The stack takes a moment to pull on first use. Watch the log for the Grafana URL, which is on a random port:

Dev Service 'lgtm' is ready — Grafana: http://localhost:<port>

Log in is not required (anonymous admin). Explore traces in Tempo, metrics in Prometheus and logs in Loki; all three are pre-provisioned as Grafana data sources by the image.

Property Default here Purpose
arconia.dev.services.lgtm.enabled false, true under observability Starts the LGTM container
arconia.otel.enabled false, true under observability Enables the OpenTelemetry SDK and OTLP export

Ports are random by default; pin them with arconia.dev.services.lgtm.grafana-port and friends if you want stable bookmarks. See the Arconia dev services documentation for the full property list.

Development only

The gateway ships no OpenTelemetry export in production builds. The observability profile exists for the local development loop; wiring a deployed gateway to a collector is a separate, not-yet-implemented concern.

UI development loop

Only needed when changing the portal:

$ cd src/main/frontend
$ corepack enable pnpm
$ pnpm install
$ pnpm dev           # proxies /api and /actuator to localhost:8080
$ pnpm test          # jsdom unit tests
$ pnpm test:stories  # Storybook story tests in real chromium
$ pnpm storybook

The gates

All must pass before any pull request:

$ ./mvnw clean verify                          # Java + UI gates, packaged jar
$ (cd src/main/frontend && pnpm test:stories)  # Storybook story tests in real chromium
$ (cd src/main/frontend && pnpm e2e)           # real-browser e2e vs a mock OIDC IdP
$ reqstool status local -p docs/reqstool       # must end "PASS"
$ openspec validate --all --strict
$ mkdocs build --strict                        # documentation

Use clean for the reqstool gate — incremental compilation truncates the generated annotation files.

That list is an ordered sequence, not a set. reqstool status reads the portal suites' JUnit output from src/main/frontend/test-results/, so running it before pnpm e2e reports requirements as unverified that are in fact covered.

reqstool status exits 0 even when it prints FAIL

Its exit code is not a gate. Read the last line of its output — the gate passes only when it ends PASS, and a script that checks $? will call a failing run green.

For the documentation gate:

$ pip install -r docs/requirements.txt
$ mkdocs build --strict

Running the gates on Podman

Podman is a supported way to run the container-backed gates, but not an unconfigured one. Both of the problems below present as a broken change rather than a broken environment — a wall of test errors, with nothing pointing at the container runtime — so they are worth setting up before the first run rather than diagnosing during it.

Export both of these; the rest of this section is why.

$ export TESTCONTAINERS_RYUK_DISABLED=true
$ export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/run/user/501/podman/podman.sock

Ryuk cannot start on rootless Podman

Ryuk is the Testcontainers resource reaper. Under a rootless Podman machine it fails to start and every container-backed test errors:

ERROR tc.testcontainers/ryuk:0.14.0 : Could not start container
Tests run: 3, Failures: 0, Errors: 3

TESTCONTAINERS_RYUK_DISABLED=true is the fix, and the consequence is that nothing reaps test containers. They accumulate across runs and each published port stays forwarded by gvproxy, so prune periodically:

$ podman container prune -f

The alternative is a rootful machine (podman machine stop && podman machine set --rootful && podman machine start), where Ryuk works. Note that rootless and rootful use separate image and container storage, so everything already pulled is pulled again.

The Floci dev service needs the in-VM socket path

io.floci:testcontainers-floci — which arconia-dev-services-floci wraps, and which the object-store storage suites depend on — bind-mounts the container runtime's socket unconditionally. Without an override it resolves to the macOS-side path, which does not exist inside the machine VM, and the failure reads as a container-creation fault rather than a missing socket:

Status 500 ... operation not supported

TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE must name the socket inside the VM, not on the host. Confirm it rather than copying it:

$ podman machine ssh 'echo "$XDG_RUNTIME_DIR/podman/podman.sock"'
/run/user/501/podman/podman.sock

The override is unnecessary on Linux, where the host path is the VM path; CI sets neither variable.

If Testcontainers cannot find a container runtime at all

Could not find a valid Docker environment

Testcontainers resolves the socket itself, so the docker CLI working proves nothing — under Podman docker is an alias, and docker info succeeds against a socket Testcontainers never consults. What it wants is /var/run/docker.sock, which podman machine start creates as a symlink into the machine's socket:

$ ls -la /var/run/docker.sock
lrwxr-xr-x  1 root  daemon  ...  /var/run/docker.sock -> ~/.local/share/containers/podman/machine/podman.sock

If it is missing, restart the machine. Exporting DOCKER_HOST from podman machine inspect also works.

The symptom can look inconsistent between suites, which is what makes it slow to diagnose: the Arconia dev services resolve the runtime differently and can keep working while tests that use Testcontainers directly fail in the same build.

Give the machine enough memory

The default machine is small. Under a parallel build the PostgreSQL, Floci and LGTM containers plus the native-image and CycloneDX steps hit container-startup timeouts and OOM kills. Raise it in place — stop, set, start; storage is preserved:

$ podman machine stop
$ podman machine set --memory 8192
$ podman machine start

A failed run is not always a failed change

Under load the machine intermittently stops answering mid-build — a connection refused, or a container dying during startup — and the whole suite errors on context load. The same command re-run unchanged is green. Retry once before debugging the code, and record the retry in the change's evidence rather than quietly re-running: a genuinely flaky test looks identical from here.

Next steps