Skip to content

Known limitations

Things DevKit does not (yet) do well, with the rationale and workarounds where applicable. This list is updated as limitations are resolved.

  • --name must be a DNS label. Names are validated against RFC 1123: 1-63 chars of lowercase [a-z0-9-], must start and end with [a-z0-9]. Uppercase, underscores, and leading/trailing hyphens are rejected. DNS-label form was chosen so the same name is safe to embed as a hostname in a future {service}.{instance}.localhost routing model without a second translation step. Name validation is centralized so every surface enforces the same rule. Migration: instances created with an older release that still allowed uppercase or underscore names (e.g. MyStack, my_stack) must be torn down with that older binary and re-created under a DNS-label name.
  • Splice container images are pulled by mutable ghcr tags, not digests. The catalogue pins the source TREE (commit SHA + post-extract ContentSHA), but Splice’s compose references every image through a single shared IMAGE_TAG variable (image: "${IMAGE_REPO}canton:${IMAGE_TAG}", ${IMAGE_REPO}splice-app:${IMAGE_TAG}, the web UIs, …). Because one variable addresses ~6 distinct images, per-image @sha256: digests cannot be injected via the compose env — a single digest can’t pin six different images.

    Instead DevKit VERIFIES post-up: after services are healthy it records each running image’s content digest (image ID) in state.json (image_digests) and, on a later up of the SAME version, WARNs if a digest changed — i.e. a mutable ghcr tag was republished under you (restart reuses the existing containers, so no re-check happens there). This is a warning, not a gate (a digest can legitimately change if you manually re-pull), and it’s best-effort (a capture failure just skips the check). True digest-pinning at pull time would need upstream Splice to expose per-image digest variables in its compose.

  • composeContext rebuilds env from registry state. down / restart / pause / clean need the env that was passed to up; DevKit reconstructs it from state.json so a fresh shell can still operate the instance. (logs and creds read the registry/containers directly and need no env reconstruction.) Any new env var a future Splice release adds that is not captured in state will silently break operations from a fresh shell.
  • Integration coverage for localnet up against real Splice runs nightly, not on every PR. Unit tests cover parsers and orchestration on every PR, but the end-to-end bring-up flow runs only nightly (and on PRs labeled run-integration) via .github/workflows/integration.yml, so drift in the upstream Splice compose contract can land up to a day before CI notices.
  • Splice’s full stack wants ~12 GB of Docker memory. cluster/compose/localnet/resource-constraints.yaml (from canton-network/splice) sums to canton 4 GB + splice 3 GB + postgres 2 GB + console 2 GB + 7 UI services @ 256-512 MB (plus nginx/swagger-ui) ≈ 13 GB of limits — DevKit’s coded recommendation is 12 GB. In practice a single instance runs on 7-8 GB because most of those limits are headroom. But:

    • Two concurrent instances exceed 8 GB Docker → splice in one of them gets OOM-restarted by docker, never reaches healthy, and WaitForHealthy times out at 25 min.
    • GitHub ubuntu-latest runners have 16 GB RAM on public repos but only 8 GB on private repos — on private-repo runners up starts but Splice’s onboarding may not complete; use a larger runner class or a self-hosted runner there.
    • Docker Desktop defaults to 50% of host memory (8 GB on a 16 GB Mac). Bump via Settings → Resources before running multi-instance scenarios.

    The preflight check enforces a per-version hard floor: 8 GB for the 0.6 line (0.6.3, 0.6.4, 0.6.9/latest, 0.6.10, 0.6.11, the V2 alpha — and any uncurated 0.6.x tag, which inherits the strictest catalogued floor for its major), 4 GB for 0.5.18 and only for tags whose major has no catalogued entry. The 12 GB figure is the coded recommendation threshold (recommended_memory_bytes) — below it preflight WARNs but does not refuse.

    On timeout, WaitForHealthy now dumps the last docker compose ps snapshot in its error so the stuck service + state are visible without re-running anything.

  • restore requires the target instance to already exist (was up at least once). A restore loads a pg_dumpall stream into the instance’s EXISTING Postgres volume via a throwaway loader container. That volume is only a Docker-Compose-owned volume after an up created it. Restoring into a never-up instance would force the loader’s docker run -v <vol>:... to CREATE the volume out of band — producing a volume Compose does not own. docker compose down --volumes (used by localnet down and localnet remove --force) only removes volumes Compose itself created, so such an orphan volume survives teardown and is silently adopted (with a “volume … already exists but was not created by Docker Compose” warning) on the next up. To keep the “never create a volume outside Compose” invariant, restore now refuses when the instance is not registered and tells the user to run localnet up <name> first. This also applies to cross-name restore: the target name must have been up too. Workaround: run localnet up <name> (or up <newname> for a cross-name restore) before localnet restore.

  • The restore precondition is enforced via the registry, not a Docker volume-ownership check (known gap). The guard checks that a registry record exists for the target instance, which is a PROXY for “the Compose-owned volume exists”. up writes state.json early (at “creating” time, before the volume is guaranteed to exist), so a crashed/half-finished up can leave a registry entry with no volume — in which case restore would still proceed and the loader would re-create the volume out of band, reintroducing the orphan. The robust check is to verify the volume carries Compose’s ownership labels (com.docker.compose.project=canton-<name> and com.docker.compose.volume=postgres) via a label-filtered docker volume ls — a bare docker volume inspect <name> is NOT sufficient because a detached docker run -v volume exists under the same name yet lacks those labels. That label-based volume-ownership check is intentionally deferred for now; the registry gate covers the common case. Tracked as a follow-up.

  • Homebrew formula targets macOS arm64 and Linux x86_64 only. Matches the release matrix. macOS Intel, Linux ARM, and Windows are intentionally out of scope.
  • Windows users: use the standalone zip from GitHub Releases rather than DPM until the Windows .exe path through DPM is verified.

DevKit runs a host-level shared Prometheus + Grafana stack — one stack serves every running LocalNet via file-based service discovery, refcounted by target file. See Observability for the topology.

  • up --observability-mode selects the sidecar stack. auto (default) serves metrics from the shared stack and skips the per-instance Prometheus + Grafana overlay when the shared stack is reachable — avoiding roughly ~600 MiB of duplicated overhead per environment. shared forces shared-only; per-instance forces the overlay. The choice is persisted, so a re-up preserves it.
  • Why the per-instance fallback is kept. The per-instance scrape uses in-network service DNS (canton:10013) rather than host.docker.internal, so it works on any platform regardless of the Linux host-gateway mapping; auto falls back to it when the shared stack can’t be started.
  • Native-Linux validation. scripts/e2e-observability.sh (self-hosted Linux CI) brings up a shared-mode instance and asserts the shared Prometheus scrapes it via host.docker.internal. auto also health-probes the shared Prometheus and falls back to the per-instance overlay when it is up but not serving. Making shared the default (and dropping the overlay entirely) follows that e2e going green; until then per-instance is the platform-independent escape hatch.