Skip to content

Local development

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

Prerequisites

  • JDK 25 (Temurin)
  • 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, so they reach neither the jar nor the container image built from it — 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.

A checksum failure means your local database predates a schema edit

While the project is pre-1.0 the schema is one migration, edited in place, so its checksum changes whenever the schema does and Flyway refuses a database built from an older copy:

Migration checksum mismatch for migration version 1

There is no upgrade path and none is wanted — no deployment carries data worth keeping yet. Drop the database and let it rebuild:

$ dropdb skillsgateway && createdb skillsgateway

A container-backed database (the Arconia dev service, Testcontainers, the e2e compose stack) rebuilds from scratch anyway and needs nothing.

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
$ export SKILLSGATEWAY_OIDC_ISSUER=https://idp.example.com

The client registration id is idp, and the defaults are the placeholders change-me and idp.invalid so that the registration always exists. A gateway started without real values will not complete a login.

The issuer is not optional: once any of the SGW_OIDC_* values is real, the gateway refuses to start without SKILLSGATEWAY_OIDC_ISSUER, and names the settings that tripped it. Use the issuer value from your provider's /.well-known/openid-configuration. See Configuration.

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 does not affect the CSRF token either — it opens authentication, not forgery protection — so a mutation driven by curl still needs the X-XSRF-TOKEN header. See REST API.

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

The application-context budget

./mvnw verify also enforces a budget on how many distinct Spring application contexts the test suite starts. ContextBudgetTests resolves every Spring test class's merged context configuration — the framework's own context cache key — without loading a single context, counts the distinct ones, and fails when the total crosses the budget in that class. The breakdown, and which classes share which property set, is written to target/context-budget.txt on every run.

The budget exists because context growth is invisible until it is fatal. Each distinct property set is a whole live context — connection pool, JGit storage, embedded Tomcat, and its own PostgreSQL container, since the Arconia dev service registers one per context. Enough of them and the fork runs out of heap, which is what happened in #302: a partial test count, no error, and nothing in the build that noticed the cause.

If a change makes the budget fail, the question to ask first is not "what number do I put here". It is:

  • Could this test reuse an existing property set? Two sets that differ only by a value nothing asserts on are two contexts for one posture. Suites that need only their own administrator's name share one declaration in AbstractNamedAdminsTest, the claim-mapping suites share AbstractClaimMappingTest, and the two credential-lifetime postures share AbstractCredentialLifetimeTest.
  • Could this test use the shared context outright? A class that cannot extend AbstractGatewayTest — because its single inheritance slot is already spent — can still land on the same cache key by naming @GatewayContext, the annotation that declares the shared property set. Copying the property list instead works only until the copies drift, and the drift is silently another context. ConditionalWriteFidelityTests is the worked example.
  • Could this test share an existing sibling abstract class? A suite that copies most of another abstract test's property set to add a few of its own — rather than genuinely needing a different posture — is a second context for the same arrangement. Move the differing properties into the shared base class once they are true of every subclass, and extend it. ExternalSourceResolutionTests folding into AbstractExternalSourceTest is the worked example: the transfer budgets it deliberately exceeds were loose enough that the two suites already sharing that base class (SnapshotClosureTests, ClosureCompletenessTests) stay well under them.
  • Does this test need the gateway at all? Configuration that is consumed while the context is built — a binding, a registrar, a startup refusal — is provable with an ApplicationContextRunner holding just the beans under test. That is the only conversion that removes a context outright rather than trading it for a cheaper one: the runner's context is created and closed per test and never enters the cache. StorageBackendSelectionTests, RoleBootstrapGuardTests, ExternalConnectorRegistrationTests and OidcRegistrationConfigurationTests are the worked examples. The last of those shows the variant to reach for when the assertion is about the shipped application.yaml rather than about a hand-written property: add ConfigDataApplicationContextInitializer to the runner and it reads the same file the application does. A class forced to share a full-server posture group (like the forwarded-headers suites, see below) only because it needs some member of that group's configuration, while its own assertion is against wiring rather than a live request, can move the same way: a WebApplicationContextRunner over just the @Configuration under test. ForwardedHeadersRelativeRedirectsTests is the worked example — it asserts a FilterRegistrationBean's own field, which needs ForwardedHeadersConfig and nothing else the other five postures in that group boot a real server for.
  • Is the property genuinely load-bearing? A property set equal to a default configures nothing and costs a context.

Two things about slices are worth knowing before reaching for one, because both are easy to discover the expensive way:

  • A slice for a test that currently shares the base context makes the count worse. The base context is built whichever way that test goes, so the slice is an additional context, not a replacement. Slices pay off on the classes that own a context today — the ones carrying their own @TestPropertySource.
  • The slice annotations are not on the classpath. Spring Boot 4 ships only @JsonTest in spring-boot-test-autoconfigure; @WebMvcTest, @JdbcTest and @RestClientTest live in spring-boot-webmvc-test, spring-boot-jdbc-test and spring-boot-restclient-test, none of which this project depends on. And @JdbcTest replaces the datasource with an embedded one by default, which for a schema built by Flyway on PostgreSQL-specific SQL would mean no longer testing the database the gateway runs on — any adoption needs @AutoConfigureTestDatabase(replace = NONE).

Raising the number is a legitimate answer when a posture genuinely needs its own context; it is a deliberate edit with the reason in the commit message, not a formality.

Measured. The surefire spring.test.context.cache.maxSize bound (separate from the ContextBudgetTests count above) was 8 from #302 until it was measured, not tuned, against 25 distinct contexts on 2026-09-16: 8 forces 4 extra context rebuilds beyond the 25 unavoidable ones and a 6:46 run; 16 and 32 both eliminate the rebuilds (25 misses, the floor) at 5:44 and 5:00 — the two are equal on rebuild count, so that further drop is noise. The bound is now 16: it gets the full speedup and still caps how many of the 25 contexts can be resident at once, rather than letting all of them stay live simultaneously, which is the condition that exhausted CI's heap in #302.

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 CycloneDX step 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