A dev service provisions a Testcontainers container — PostgreSQL, Keycloak — and wires its coordinates into your application as plain configuration, with no docker run and no hand-copied JDBC URL. Providers run outside the application, in the host that starts them: vidocq:dev, vidocq:run (opt-in), or a JUnit test run. Whichever host it is, the application sees the same thing — a devservices section of the startup report, and, in a dev launch, a dev console panel of the same name — without depending on Testcontainers or Docker itself.

This page covers where dev services run, how the application sees them, and how a test gets one. DEV_SERVICES.md at the repository root covers the providers themselves — PostgreSQL, Keycloak, multi-datasource, stable ports — in full.

Where they run NEW

Three hosts can start dev services, all through the same vidocq-runtime-devservices-host machinery:

Host Starts Default

vidocq:dev

Every provider whose appliesWhen matches, once, before the first child JVM; survives hot reloads.

On

vidocq:run

The same providers, in the same forked-JVM launch the production launcher uses.

Off — opt in with -Dvidocq.dev.devServices=true

A JUnit test run

The same providers, for the whole test run, before any test class.

Off — opt in by adding the dependencies (see In tests NEW)

Testcontainers and the Docker client never reach the application’s own module path: they live in vidocq-runtime-devservices-host and the providers, consumed only by the host. The application depends on nothing but vidocq-runtime-devservices-extension, which reads a small state file and has no Testcontainers, no Maven API and no JSON library of its own.

Every dev services jar is a named module NEW, as every published Vidocq jar is: none of them reaches a module path as an automatic module. The providers name Testcontainers by the name its jar’s file gives it (testcontainers), which is fine where they run, the plugin’s class path, and they need Testcontainers' core only: the PostgreSQL provider configures a plain container rather than Testcontainers' postgresql module. vidocq-runtime-devservices-junit still lands on a test run’s class path, where its META-INF/services entry finds it; on a module path, its provides does.

Configuration sources NEW

A provider’s keys are one of two kinds:

  • Opt-out keys decide whether a service starts at all — vidocq.pool[.<name>].url, mp.jwt.verify.issuer. Only an explicit -D, an environment variable, or the dev-goal configuration switches a service off through them, never vidocq.properties/application.properties: a production default baked into the application’s own file must never silently switch a dev container off. A provider may still read that file to learn what the application is configured for (A PostgreSQL container only for a PostgreSQL application NEW).

  • Tuning keys, everything under vidocq.dev. — vidocq.dev.postgres.port, vidocq.dev.postgres.datasources, vidocq.dev.reuse, vidocq.dev.devServices — say how to start a service that is already going to start. These are read from the application’s own files too, in the runtime’s own order (target/classes, after resource filtering), so a stable port pinned once in vidocq.properties now works without repeating a -D on every invocation:

# src/main/resources/vidocq.properties
vidocq.dev.postgres.port=55432
vidocq.dev.reuse=true

vidocq.pool.url in that same file is still no opt-out: a PostgreSQL URL there never disables the PostgreSQL dev service. What the file tells the provider is which database the application uses.

A PostgreSQL container only for a PostgreSQL application NEW

With vidocq-runtime-devservice-postgres among the plugin’s dependencies, the PostgreSQL dev service decides, for the @Default datasource (vidocq.pool.url) and then for each name of vidocq.dev.postgres.datasources (vidocq.pool.<name>.url), in this order:

When Container What it says

The URL is given explicitly: a -D, an environment variable, the goal’s configuration

No

Nothing in the report; the log keeps DevService 'postgres' skipped (already configured)

The application’s file gives a jdbc: URL of another database — jdbc:h2:mem:app, jdbc:mysql://db/app, whatever the case

No

not started: vidocq.pool.url is jdbc:h2, not PostgreSQL

The file gives a jdbc:postgresql: URL — the production one — or a wrapper driver’s in front of it, such as jdbc:otel:postgresql: or jdbc:p6spy:postgresql: (not Testcontainers' jdbc:tc:)

Yes, and its URL replaces the file’s under the dev host

The service, as before

No URL at all, or a value that is not a readable jdbc: URL, such as a ${db.url} expression, jdbc:${db.kind}://… or jdbc: alone

Only when org.postgresql.Driver is on the application’s class path

Without the driver: not started: no vidocq.pool.url and no PostgreSQL driver (org.postgresql.Driver) on the class path

The class path is the one the application will run with: its runtime dependencies under vidocq:dev and vidocq:run, its test dependencies under vidocq:test and a JUnit run. An application on PostgreSQL at runtime and on H2 in its tests, whose file names PostgreSQL, still gets its container in a test run. The driver is looked up as a .class entry, never loaded.

The file is read where the build put it, target/classes; on a tree not built yet, vidocq:dev reads src/main/resources instead. A vidocq.properties edited since the last build is read as built: run process-resources (or any build) after changing the URL. vidocq:test resolves the test dependencies at start, dev services on or off, as the tests it forks need them anyway.

When one datasource gets a container and another does not, the log still says why for the other: Postgres dev service: datasource 'audit' not started: vidocq.pool.audit.url is jdbc:h2, not PostgreSQL.

An application that relied on the container with no URL anywhere and the PostgreSQL driver in test scope only no longer gets one under vidocq:dev and vidocq:run, which look at the runtime class path. Write its production URL in vidocq.properties (vidocq.pool.url=jdbc:postgresql://…): the container is back, its URL replacing that one.

A reason names the key and the URL’s scheme — jdbc:h2, as written — never the rest of the URL, which may carry a host, a user or a password. When no datasource gets a container, the reasons of all of them are joined with ; `. The log says `DevService 'postgres' not started: <reason>, the state file keeps it, and the report shows it (The startup report section and dev console panel NEW).

Container names and labels NEW

A container a dev service starts is named after Vidocq, the application and the service, so docker ps tells it apart from the rest:

vidocq-dev-mcp-tasks-server-postgres-3f9a2c01
vidocq-dev-mcp-tasks-server-postgres-analytics-7b04e1d9
vidocq-dev-shop-keycloak-c2a18f5e

The application is the name of the project’s directory, and a named datasource follows the service. A Docker name is unique on the machine, so the last part keeps two applications, two datasources, or vidocq:dev and the tests of the same project apart. It is random for a container that is not reused. For a reused one (vidocq.dev.reuse=true), it is a digest of the application’s path and the container’s configuration: the name stays the same from one session to the next while the configuration does, and changes with it. Testcontainers finds a reused container by a hash that covers its name.

vidocq.dev.container-prefix replaces vidocq-dev-, as any tuning key, from a -D or from vidocq.properties. It must start a valid Docker name, letters, digits, _, . and -, and start with a letter or a digit; otherwise the start fails with a message that names the key.

Each container also carries three labels: io.vidocq.dev=true, io.vidocq.dev.app=<application> and io.vidocq.dev.service=<service>. docker ps --filter label=io.vidocq.dev lists them all, whatever their prefix. The testcontainers-ryuk-… container that removes them at the end keeps its own name.

vidocq:run (opt-in) NEW

vidocq:run starts the application the way the production launcher does, so it starts no container unless asked — the same vidocq.dev.devServices tuning key turns it on, as a -D or in vidocq.properties:

mvn vidocq:run -Dvidocq.dev.devServices=true

Once on, vidocq:run and vidocq:dev share every other piece: the providers, the "Connection information" console block, target/vidocq-dev-services.properties, the state file, and the extension added to the child’s module path. Ctrl+C, or the process exiting on its own, stops the containers exactly once.

Switching dev services on and off NEW

Every host — vidocq:dev, vidocq:run and the JUnit listener — reads vidocq.dev.devServices the same way, first match wins:

  1. the explicit value: a -Dvidocq.dev.devServices=…, the goal’s <devServices> configuration, or (under the JUnit host) the system property;

  2. then the application’s files, vidocq.properties or application.properties;

  3. then the host’s default: on for vidocq:dev and tests, off for vidocq:run.

The value is true or false, trimmed, in any case; anything else fails the goal, or the test run, with a message naming the key and the value. A vidocq.properties that says true opts every vidocq:run into dev services, and mvn vidocq:run -Dvidocq.dev.devServices=false still turns them off for that one run.

In tests NEW

A test that only needs a database adds the JUnit host’s listener and the provider it wants, both test scope:

<dependency>
    <groupId>io.vidocq.runtime</groupId>
    <artifactId>vidocq-runtime-devservices-junit</artifactId>
    <version>0.4.0-SNAPSHOT</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.vidocq.runtime</groupId>
    <artifactId>vidocq-runtime-devservice-postgres</artifactId>
    <version>0.4.0-SNAPSHOT</version>
    <scope>test</scope>
</dependency>

No annotation is needed in the tests themselves: vidocq-runtime-devservices-junit registers a JUnit Platform LauncherSessionListener, discovered through META-INF/services, that starts the providers found on the test class path once, before any test class, injects their coordinates as system properties (vidocq.pool.url, .username, .password, …), and stops them once the whole test run ends — one set of containers for the whole suite. -Dvidocq.dev.devServices=false, or that same key in the application’s own files, skips it entirely; without Docker or Podman, the listener fails the run fast, naming the runtime it looked for and the key that switches it off, so a test that needed the database does not time out silently.

The listener — and any LauncherSessionListener a project adds the same way — must ship as its own jar, a test-scope dependency the consuming module’s module-info.java does not requires, never compiled straight into that module’s own src/test/java. Surefire’s default module-path --patch-module folds a module’s entire target/test-classes tree into its own named module; a listener class that landed there would be invisible to ServiceLoader, which only honours an explicit provides … with … in a named module, never a META-INF/services file. Kept as a separate jar, it lands on the class path instead, in the unnamed module, where ServiceLoader finds it — which is also why no <useModulePath>false</useModulePath> is needed in a consuming project’s pom.xml.

The startup report section and dev console panel NEW

Whichever host started them, the application reads one state file, <basedir>/target/vidocq-dev-services.json — <basedir> is always the project’s own pom.xml directory, unaffected by a custom project.build.directory — through vidocq-runtime-devservices-extension, which the host adds to the child’s module path (the Maven-plugin hosts) or which arrives on the class path (the JUnit host, transitively with vidocq-runtime-devservices-junit). An application declares nothing itself.

A real run of this repository’s own end-to-end integration test (vidocq-runtime-it-devservices, -Pdocker) shows what the detailed report prints:

devservices   Dev services | 0 ms
  1 service: postgres (postgres:16-alpine at localhost:32851) — vidocq:run
  started                        2026-09-24T20:11:15.781794Z by vidocq:run
  postgres                       postgres:16-alpine, default localhost:32851
  postgres vidocq.pool.password  configured
  postgres vidocq.pool.url       jdbc:postgresql://localhost:32851/vidocq?loggerLevel=OFF
  postgres vidocq.pool.username  vidocq

The summary names every service, its image and its first endpoint, and the host that started it; the detailed report adds a row per service and, in a dev launch, every key it injected — a secret (vidocq.pool.password above) always as configured, never its value, whatever the launch mode. Without a state file or the vidocq.devservices.state system property, the summary reads no dev service: not started by vidocq:dev, vidocq:run or the test launcher — not an anomaly, the ordinary state of an application nobody has started with a dev service yet. The extension never scans target/ for a stray file on its own: a killed session’s leftover file is read again only when a later host overwrites vidocq.devservices.state, never merely because the file exists.

A provider that did not start and said why (A PostgreSQL container only for a PostgreSQL application NEW) gets a row of its own, at every verbosity: the row postgres, its value not started: vidocq.pool.url is jdbc:h2, not PostgreSQL. When no service started and at least one said why, the summary reads no dev service started. The dev console’s Dev services panel shows the same rows. The state file keeps them as "skipped": [{"id": "postgres", "reason": "…"}]; a file written before that key existed reads as none.

VIDOCQ-DEVS-001 NEW

Raised when the state file exists but could not be read — a corrupt or half-written file, most often from a very old, incompatible Vidocq version. The boot goes on; the section reads state file unreadable, and the anomaly’s message names the file’s path. Rerun the goal that starts dev services: the Maven plugin, or the JUnit listener, rewrites the file cleanly.

One state file per project NEW

The state file’s path is the project’s, not the host’s: every host of the same project shares it, and target/vidocq-dev-services.properties too. Running mvn test while vidocq:dev runs in another terminal starts the test run’s own containers and overwrites both files; when the test run ends, it marks the state file stopped, host test, although vidocq:dev’s containers still run. The running application keeps what it read at boot, but a reload reads the file again. Restart `vidocq:dev, or avoid running both at once, when the files must describe it.

The dev console panel of the same name shows the same boot facts, live: which service, its image and endpoints, and the keys it injected, a secret always as configured. The application cannot reach Docker itself, so the panel has no live samples of its own — boot facts only, no chart.

Continuous testing NEW

Continuous testing reuses the dev containers: under vidocq:dev every test run gets the session’s connection keys, and vidocq:test opens a session of its own, host vidocq:test. Each run gets -Dvidocq.dev.devServices=false, so the tests never start a second set of containers.

The tests then share the dev database: a test that deletes rows deletes your dev data.

No secret leaves the application NEW

A key whose last dot-separated segment names a secret — password, passwd, pwd, secret, token, key, credential(s), apikey, api-key, private-key, in any case — is written to the state file as "configured": true, never with its value; a URL loses its user:password@ part and any secret query parameter is masked as * before anything is written. Only target/vidocq-dev-services.properties, documented in DEV_SERVICES.md’s "Connection information", keeps a password in clear text, on purpose: it is the local, machine-only file an external tool — `psql, DataGrip, a migration runner — reads to connect to the dev database without a JDBC URL copied by hand.