Skip to main content
Version: V3

Compose deployment

The supported Infrahub Sync deployment is one host running deploy/compose/. It owns the Sync API, the Sync worker, a Prefect server, PostgreSQL, and S3-compatible object storage. A host needs Docker Compose 2.17.3 or later and nothing else — no product checkout, no Python, no uv.

Private candidates are available as release attachments. Start with Install Infrahub Sync for release access and the guides pinned for Candidate 4.

Start from the Compose quickstart, which owns release verification, image loading, init, and the first start. This page owns the configuration and reviewed-run procedure after that prerequisite.

Day 2 operations · Compose operator troubleshooting · Supported platforms and limits

The extracted bundle includes OPERATING.md with local operating instructions for that bundle. Follow the release download and checksum steps before using it.

Release prerequisite​

Use the Compose quickstart to verify and extract the release, load its image, initialize the deployment, and perform the first start. Continue here only after it reports READY.

The checksum is a claim about the archive as shipped and about nothing afterwards: preparing a deployment writes operator.env and edits the declared configuration, and both leave the extracted tree different from the archive.

The archive names the image it was qualified against. That record is image.bind, generated by the release and shipped inside the bundle, and there is no setting anywhere that names a different one:

cat image.bind

It holds the qualified platform and the same image in the three immutable forms a host can hold it under:

INFRAHUB_SYNC_IMAGE_PLATFORM=linux/amd64
INFRAHUB_SYNC_IMAGE_INDEX=<index name>@sha256:<64 hex>
INFRAHUB_SYNC_IMAGE_MANIFEST=sha256:<64 hex>
INFRAHUB_SYNC_IMAGE_CONFIG=sha256:<64 hex>

Which form a host holds is decided by the image store its Docker runs. The containerd image store — the default from Docker Engine 29 — identifies a loaded archive by the digest of a manifest it synthesizes at load time, which is the MANIFEST line; the classic store identifies it by the configuration digest, which is the CONFIG line. The INDEX line is the exported OCI index, which a host that kept the original export already holds.

The image is named by digest, never by a tag: a tag can be re-pointed between the qualification that trusted it and the run that uses it, so the entry point refuses one before it creates anything.

Load that image onto the host before the first start, from the image-linux-amd64.tar the release publishes beside the bundle. The deployment settles which form it got by itself: the index first, then the manifest digest, then the configuration digest, asking only this engine. No registry is consulted — what a registry holds says nothing about what this host can run, and a private candidate is loaded rather than pulled.

Confirming that what you loaded is what the record names is one command, and the identifier it prints is one of the record's three forms:

docker image inspect --format '{{.Id}}' <reference>

What the bundle contains​

PathWhat it is
compose.yamlThe topology: every service, network, volume, and label.
infrahub-sync-composeThe lifecycle entry point. The only thing that talks to Docker.
defaults.confShipped non-secret defaults. Tracked; safe to read.
configuration/qualification.yamlAn example declared package, for you to register explicitly. Nothing loads it.
bootstrap/databases.shThe read-only script that creates the two databases and their owners.
OPERATING.mdThis procedure, shipped inside the archive for a host that has only the archive.
skills/README.mdDiscovery and manual installation guidance for the optional agent skills.
skills/infrahub-sync-deployment/SKILL.mdRoutes deployment diagnosis and saved-plan review to the human procedure.
skills/infrahub-sync-configuration/SKILL.mdRoutes configuration drafting and registered-version validation without registry writes.
operator.envYour credentials. Generated by init, Git-ignored, never printed.
secrets/postgres-admin-passwordThe one file-mounted credential. Generated by init.
image.bindThe image this bundle was qualified against, generated by the release. Not yours to edit.
.instanceThis deployment's generated identity and selected image. Non-secret; the identity is the ownership label.

The skills are unreleased source content and are not included in Candidate 4 (3.0.0a4). A later bundle built from a revision containing these files can be installed manually by following skills/README.md; no bundle installs or updates an agent skill automatically. The operator procedures on this page and in OPERATING.md remain authoritative, and a skill grants no permission to change a deployment, registry, source, or destination.

The API and the worker share no mount, no volume, and no scratch directory. Run state travels between them through PostgreSQL and the object store, which is why the worker needs no configuration mount and why replacing it loses nothing. PostgreSQL and the object store hold the only volumes that survive a restart.

Configuration prerequisites​

That writes .instance, secrets/postgres-admin-password, and operator.env with generated passwords for the two database owner roles, the object store, and one API principal. Nothing in operator.env is yours to supply before the first start.

init needs no Docker. It reads image.bind, checks its grammar, and copies the index reference into .instance as plain text, which is what gives every later command an image name to interpolate — so init followed by status, logs, stop, restart or reset works before the image has been loaded at all. It resolves nothing and asks no engine anything.

Nothing else is needed to start. The deployment comes up with an empty configuration registry: which package it runs is a decision you make afterwards, by registering one through the Sync API. configuration/qualification.yaml in the bundle is an example of that package's shape and is never loaded for you.

The credentials a registered package references belong in operator.env before the run that needs them, and init leaves each one commented:

INFRAHUB_API_TOKEN=<your Infrahub token>

Credentials in a package are references, never values:

token:
$credential: infrahub-token
credentials:
infrahub-token:
provider: env
identifier: INFRAHUB_API_TOKEN
note

A container reads its environment once, at start. After changing a value in operator.env, run ./infrahub-sync-compose start again — that recreates the services whose environment changed. restart replaces the processes inside containers that keep the environment they were created with.

Reading from NetBox or Nautobot​

The image installs the bundled NetBox and Nautobot source adapters, so a package may declare either as its source. init leaves both token names commented in operator.env; remove the leading # only from the token entry your package names.

NETBOX_TOKEN=<your NetBox token>
# NAUTOBOT_TOKEN=<your Nautobot token>
configuration:
source:
name: netbox
settings:
url: "http://netbox.example.net:8080"
token:
$credential: netbox-token
credentials:
netbox-token:
provider: env
identifier: NETBOX_TOKEN

Nautobot uses the same shape with nautobot, nautobot-token and NAUTOBOT_TOKEN.

  • Name a URL a container can reach. The worker resolves it from inside the Compose network, where localhost is the worker itself.
  • The declared url is the one used. NETBOX_ADDRESS, NETBOX_URL, NAUTOBOT_ADDRESS and NAUTOBOT_URL exported in your shell are ignored: a registered run reads what its package declares.
  • Both tokens are optional. A deployment that reads from neither source starts normally, and a package naming one source does not need the other's token. When a package does select a source, a missing or empty token fails that run and the message names the environment variable, never its value.
  • Start again after changing either value. A container reads its environment once, at start, so ./infrahub-sync-compose start recreates the worker; restart keeps the environment it already has.

Only sync-worker receives these tokens. Registration and the default validation judge declared content without resolving a source secret, so the API and the bootstrap job never hold one.

note

That both adapters install and import is a packaging property of this release. It is not a statement that any particular NetBox or Nautobot server version has been qualified against it.

After the first start​

start runs preflight itself, so the separate call is optional. It is a diagnostic rather than a read-only one: it may replace the image recorded in .instance with whichever of the record's three forms this engine resolved. It starts no service, changes nothing in operator.env or secrets/, leaves the instance identity alone, and reaches no source or destination.

preflight refuses before anything is created, and every refusal is one family name followed by a fixed sentence. It never renders a credential value.

FamilyWhat to do
compose-unreadableDocker Compose reported no version this can compare. Check the installation.
compose-too-oldInstall Docker Compose 2.17.3 or later.
path-unwritable, path-unreadable, path-missingThe bundle directory, secrets/, or the generated .instance is not usable by this user. A refused state write leaves the previous .instance exactly as it was.
credentials-missingThe named settings in operator.env are empty.
image-binding-missingimage.bind is absent or unreadable, so this bundle names no image. Extract the archive again.
image-binding-invalidimage.bind is missing one of its four settings, names a platform this bundle is not qualified on, or names something that is not an immutable digest.
image-binding-mismatch.instance names an image image.bind does not. Run init.
image-not-immutableThe selected reference carries a malformed digest.
image-unresolvableThis engine holds none of the three identities image.bind names. Load image-linux-amd64.tar on this host; no registry is consulted.
image-platform-unqualifiedThe image this engine resolved is another architecture. It is not a fallback: load the qualified one.
port-occupiedSomething else already holds one of the required loopback binds. Preflight asks the engine for the bind rather than reading container names, so a foreign container published on any address and a host process that is not a container are both found.
port-unprovableThe disposable bind probe could not run at all, so the port was neither proved free nor proved held. Check that the Sync image runs on this host.
docker-unavailableDocker could not enumerate or inspect this instance. This is not evidence that the deployment is stopped; restore Docker access and repeat the command.

start runs preflight, brings the deployment up, and returns only once the Sync API reports a worker that has registered. It is safe to repeat: bootstrap converges the two databases, their owner roles, the product schema, the artifact bucket, the Prefect process work pool, and the installed deployment, and a second run creates none of them twice. It registers nothing, so a repeat leaves every configuration you registered exactly as it was.

READY describes this deployment's own dependencies and a live worker, and nothing about a configuration. A deployment reaches READY with an empty registry, no credentials set, and no reachable destination; whether a registered package is valid is answered by configs validate, and whether its destination answers is answered by the first run against it.

Both published surfaces bind to loopback by default:

SurfaceAddress
Sync APIhttp://127.0.0.1:8000
Prefect UI and APIhttp://127.0.0.1:4200

External exposure and TLS termination are not part of this topology.

Prepare the destination schema​

The existing-Infrahub tutorials use a small destination schema with two kinds, TestingSite and TestingDevice:

version: '1.0'
nodes:
- name: Site
namespace: Testing
human_friendly_id: [name__value]
uniqueness_constraints: [[name__value]]
attributes:
- name: name
kind: Text
optional: false
- name: Device
namespace: Testing
human_friendly_id: [name__value]
uniqueness_constraints: [[name__value]]
attributes:
- name: name
kind: Text
optional: false
- name: model
kind: Text
optional: false
- name: serial
kind: Text
optional: false
relationships:
- name: site
peer: TestingSite
kind: Attribute
cardinality: one
optional: false

Save it as schema.yml, then use the Infrahub SDK CLI on the Docker host to load it. Install the CLI outside the deployment containers:

python3 -m venv .schema-tools
.schema-tools/bin/python -m pip install 'infrahub-sdk[ctl]'
.schema-tools/bin/infrahubctl version

Set the destination address and token for one command only. Replace the address with the URL in destination.settings.url; do not source operator.env.

(
export INFRAHUB_ADDRESS="https://infrahub.example.invalid"
export INFRAHUB_API_TOKEN="$(sed -n 's/^INFRAHUB_API_TOKEN=//p' operator.env)"
.schema-tools/bin/infrahubctl schema load schema.yml --branch BRANCH_NAME
)

BRANCH_NAME must already exist. List or, when needed, create it with the same scoped environment:

(
export INFRAHUB_ADDRESS="https://infrahub.example.invalid"
export INFRAHUB_API_TOKEN="$(sed -n 's/^INFRAHUB_API_TOKEN=//p' operator.env)"
.schema-tools/bin/infrahubctl branch list
# Only if you want to create a new branch:
.schema-tools/bin/infrahubctl branch create BRANCH_NAME
)

Use this branch for the later diff and apply. Omit --branch from schema load to target Infrahub's default branch. The destination schema is your Infrahub instance's responsibility; this deployment's CLI does not load it.

Use it​

cli runs the shipped CLI against this deployment, in a container of the same image, on the deployment's own network:

./infrahub-sync-compose cli configs list

There is nothing to authenticate by hand. init generated one value and wrote it to both settings that name it — INFRAHUB_SYNC_SERVICE_BEARER_TOKENS, the principal the Sync API accepts, and INFRAHUB_SYNC_API_TOKEN, what cli presents. They are one credential: if you change one, change the other to the same value.

An operator.env generated by an earlier alpha has no INFRAHUB_SYNC_API_TOKEN line at all, and nothing adds one: init leaves an existing file exactly as it found it, and this alpha migrates no state in place. Every cli call against such a bundle fails to authenticate. Add the setting by hand and give it the token already inside that same file's INFRAHUB_SYNC_SERVICE_BEARER_TOKENS, which is the principal the deployment authorizes; nothing rotates it for you, and any other value leaves the CLI unauthenticated again. Never source operator.env to read it: that file holds every other credential of the deployment.

Like preflight, cli resolves the image before it runs anything, so it too may replace the image recorded in .instance with whichever of the record's three forms this engine resolved. It changes nothing else in the bundle.

Each call is one container: nothing is published, no service is started as a side effect, no volume is kept, and the container is removed when the command ends. cli --help displays help for the CLI, and everything after cli is passed through as given — an argument holding spaces stays one argument.

Prepare a configuration​

The NetBox and Nautobot tutorials include schema and package preparation, registration, validation, and a first preview. Continue here for the full operator sequence and lifecycle commands. After changing operator.env, run ./infrahub-sync-compose start so services receive the new environment. Source credentials are optional for startup but required for a run that uses them.

Registering a package​

A package is a file on your machine, and the CLI runs in a container. --package bridges that for one call: the file's bytes are copied into a private directory, mounted read-only at /input/package.yaml, and removed when the command ends. Your own file is never mounted and never modified, so its permissions are left alone.

./infrahub-sync-compose cli --package ./package.yml -- \
configs register /input/package.yaml --reason 'register my configuration'

Two refusals belong to --package alone: cli-usage when it is given no file, and package-unusable when the named path is not a readable regular file. Both name the path and never its content.

-- separates options for the wrapper from options for the CLI. After registration the declared content lives in PostgreSQL: no host file is mounted by the API or the worker, and the file you registered from is not needed again. Every later command names the configuration by its identifier and version.

Two things a package declares are resolved elsewhere:

  • Its URLs are resolved inside a container. localhost there is the container itself. Name an address the Compose network can reach.
  • Its credentials are references. The value lives in operator.env and is resolved by the service that needs it, so no secret is registered, stored or echoed. Add the credential before the run that needs it and run start again to recreate the services whose environment changed.

The operator sequence​

./infrahub-sync-compose cli configs list
# Add destination/source credentials to operator.env before planning; recreate affected services
# using start after changed environment; restart alone retains the old container environment.
./infrahub-sync-compose start
./infrahub-sync-compose cli --package ./package.yml -- configs register /input/package.yaml --reason 'register my configuration'
./infrahub-sync-compose cli configs show CONFIG_ID
./infrahub-sync-compose cli configs versions CONFIG_ID
./infrahub-sync-compose cli configs validate CONFIG_ID 1
./infrahub-sync-compose cli diff --config-id CONFIG_ID --version 1 --branch main --reason 'review initial sync'
./infrahub-sync-compose cli runs plan RUN_ID --detail
./infrahub-sync-compose cli apply RUN_ID --expected-checksum CHECKSUM --branch main --reason 'apply reviewed initial sync'
./infrahub-sync-compose cli runs show RUN_ID
./infrahub-sync-compose cli runs results RUN_ID
./infrahub-sync-compose cli diff --config-id CONFIG_ID --version 1 --branch main --reason 'verify unchanged source'
# Versioning is explicit, and never rewrites a registered version:
./infrahub-sync-compose cli --package ./edited-package.yml -- configs version CONFIG_ID /input/package.yaml --reason 'register edited configuration'

CONFIG_ID, RUN_ID and CHECKSUM are results the previous commands printed. runs show reports the run's phase, outcome and Prefect correlation; runs results prints what the service recorded for it as JSON.

On a new deployment, configs list prints no command output and exits zero; the Container … Creating/Created lines are Compose progress. The plan output does not repeat its branch: apply must use the same --branch supplied to diff. This release writes saved-plan format 3; it can review format 2 but refuses to apply it, and does not support older formats.

A null optional cardinality-one relationship in an update does not clear the destination field. The plan format cannot distinguish an absent relationship from an intended clear, so it is omitted and the worker logs a warning.

runs show keeps operation: plan; phase: applied and execution_state: completed confirm completion. Result counts come from the plan, not executed writes. Deletes are recorded but never executed; use runs plan RUN_ID --detail to identify each (not executed) delete. With an unchanged, fully applied mapping, the next plan has no creates or updates except for any recurring recorded delete.

If the final output from apply still shows execution_state: running, run runs show again. It settles within seconds; confirm phase: applied and execution_state: completed before treating the apply as finished.

Over HTTP or from Python​

The same deployment answers directly. /status needs no credential:

export INFRAHUB_SYNC_API_URL=http://127.0.0.1:8000
curl -sS "$INFRAHUB_SYNC_API_URL/status"

An authenticated call is what cli is for — ./infrahub-sync-compose cli configs list reads the same route. For a route the CLI has no command for, put the header in a private curl configuration rather than on the command line, where the value would be visible to every process on the host while the request runs:

umask 077
CURL_CONFIG=$(mktemp)
# `printf` is a shell builtin and `sed` never sees the value, so the token
# reaches no process argument list. Run this from the bundle directory.
printf 'header = "Authorization: Bearer %s"\n' \
"$(sed -n 's/^INFRAHUB_SYNC_SERVICE_BEARER_TOKENS=.*"token": "\([^"]*\)".*/\1/p' operator.env)" \
> "$CURL_CONFIG"

curl -sS --config "$CURL_CONFIG" "$INFRAHUB_SYNC_API_URL/configs"

rm -f "$CURL_CONFIG"

Never source operator.env: it holds every other credential of the deployment.

From Python:

from infrahub_sync.client import SyncClient

with SyncClient("http://127.0.0.1:8000", "<the generated principal token>") as client:
print(client.get_status().worker.state)

Plan verification details​

A managed write is admitted once and approved against the checksum of the plan you reviewed. Nothing is written at the destination until the apply names that exact checksum and confirms.

The operator sequence above is the authoritative command sequence for planning, reviewing, applying, and reading a run. The following API verification is optional and does not create another plan or apply sequence.

An independent verification of a retained plan is the one step here with no CLI command. It is a direct authenticated call, so it creates its own private curl configuration and removes it afterwards:

umask 077
CURL_CONFIG=$(mktemp)
# `printf` is a shell builtin and `sed` never sees the value, so the token
# reaches no process argument list. Run this from the bundle directory.
printf 'header = "Authorization: Bearer %s"\n' \
"$(sed -n 's/^INFRAHUB_SYNC_SERVICE_BEARER_TOKENS=.*"token": "\([^"]*\)".*/\1/p' operator.env)" \
> "$CURL_CONFIG"

curl -sS -X POST --config "$CURL_CONFIG" \
-H "Idempotency-Key: $(uuidgen)" -H 'Content-Type: application/json' \
-d '{"reason": "second opinion before applying"}' \
"$INFRAHUB_SYNC_API_URL/runs/RUN_ID/verify"

rm -f "$CURL_CONFIG"

Status​

./infrahub-sync-compose status
StateExitWhat it means
READY0Dependencies answer, the API answers, and a registered worker is sending heartbeats.
Refusal1The command did not run; follow its message, such as no-instance before init.
DEGRADED3Something owned exists, but that is not all true. A stopped or hung worker lands here.
STOPPED4No container of this instance is running.

Container state establishes absence and nothing else. A paused worker keeps looking running to Docker; the deployment reports DEGRADED about thirty seconds after its heartbeat stops, because readiness comes from what the Sync API reports about the Prefect registry rather than from a process being up.

A worker that has been stopped outright reaches DEGRADED the same way, and for the same reason it is not STOPPED: the API still answers, submissions are still accepted, and they will sit unclaimed until a worker comes back. Start the worker again with ./infrahub-sync-compose restart; it verifies ownership, replaces the API and worker processes, and waits for READY without changing any durable record.

Logs​

./infrahub-sync-compose logs
./infrahub-sync-compose logs sync-worker

The tail is bounded (200 lines per service by default; set INFRAHUB_SYNC_LOG_LINES). Credentials do not appear in it: run failures cross one redaction boundary before anything is rendered.

Stop, restart, and reset​

Before planned maintenance, coordinate with everyone submitting work to this deployment to stop new submissions. For each RUN_ID returned by a submission, check:

./infrahub-sync-compose cli runs show RUN_ID

Wait until its execution_state is completed, failed, cancelled, abandoned, or interrupted. These are terminal execution states; a missing or other value does not confirm a terminal execution. status can report READY while work is running: it checks health, not idleness. These checks cover only known runs; if you cannot account for all submitted work, postpone maintenance.

Stop and resume the deployment​

stop stops all services while preserving their containers and volumes:

./infrahub-sync-compose stop
./infrahub-sync-compose status

Expect STOPPED and exit code 4. To resume the deployment, use start:

./infrahub-sync-compose start
./infrahub-sync-compose status

Expect READY and exit code 0 before submitting work. start starts the PostgreSQL, object storage, and Prefect dependencies as well as the API and worker.

Restart the API and worker​

When the dependencies are running, use restart to restart only the API and worker processes:

./infrahub-sync-compose restart
./infrahub-sync-compose status

restart waits for READY and preserves durable records. It does not start stopped dependencies, so use start after a full deployment stop.

Verify a retained configuration​

Before maintenance, read a version of an existing registered configuration:

./infrahub-sync-compose cli configs show CONFIG_ID --version VERSION

Replace CONFIG_ID and VERSION with the identifier and version you registered. Save the output, then repeat the command after the deployment reports READY. Confirm that config_id, registry_version, package_checksum, and declared_content match the earlier output. This checks that the configuration version remains available; READY alone only checks deployment health.

Reset the deployment​

reset removes this instance's containers, network, and volumes. Use it only when you intend to discard the deployment's data:

./infrahub-sync-compose reset <instance identity>

stop, restart, and reset verify the instance labels on the deployment's resources before changing anything. A resource that is missing is nothing to remove; a resource that exists under another label is refused, and the whole operation stops before its first mutation:

infrahub-sync: foreign-resource: these existing resources are not labelled for
instance 3a5dbb1bc6d569b7: container/9c2f1ea41b7d

reset additionally requires you to repeat the identity it just displayed. There is no forcing flag. It leaves operator.env and secrets/ in place, so the same credentials serve the next deployment you start from this bundle.

Starting again after a reset is a cold bootstrap, not a resumption. Run init to generate a new identity — the old one labelled volumes that no longer exist — and then start. Bootstrap creates the two databases, the product schema, the bucket, the work pool, and the deployment from nothing, and the deployment reaches READY with an empty configuration registry, no runs and no artifacts behind it. Register your package again afterwards; a reset removes what you registered along with everything else.

Replacing this alpha​

This alpha promises no in-place state migration and no backup or restore. Replacing it is the reset above followed by a fresh deployment of the same bundle and the same image:

Use the quickstart lifecycle commands after reset to create the replacement deployment; it receives a new identity and starts empty.

Prior run history, retained plans, and artifacts do not survive that. Keep anything you need outside the deployment before you reset.

When the outcome of a write is uncertain​

If a write ends without proving what reached the destination, the deployment does not retry it. Repeating a write whose outcome is unknown is the one thing that could turn an uncertain state into a wrong one, so the run is left terminal and the record carries what is known.

reconciliation_required is a field of the run itself rather than something buried in its evidence, so deciding whether a run needs reconciling never means parsing a failure.

cli runs show prints it, beside the phase, the outcome and the Prefect correlation, and prints it for both of its values: false is an answer, and a field that appeared only when it was true could not be told apart from one this command does not report. cli runs results prints what the run itself recorded.

./infrahub-sync-compose cli runs show RUN_ID
./infrahub-sync-compose cli runs results RUN_ID

Two different records mean an uncertain write. A write execution that ended without reporting records phase interrupted and outcome ambiguous, and sets reconciliation_required. An apply that began writing and then failed records phase apply-failed and outcome failed, with summary.may_have_partially_written set and results.apply_failure naming the stage, the error type, the operations already applied, and the one that failed.

reconciliation_required is a write-only verdict: an interrupted plan or verify cannot have written, so it is not set for one, and nothing sets it back to false.

In both cases, read the record, inspect the destination, and then take a fresh plan run. A new plan reads the destination as it now is, so what it proposes is what is still outstanding. The terminal run is never reopened and no later run inherits its admission.

Developing the bundle​

The bundle's own gates run through Invoke, against an image the container gate already built. Neither builds one:

CommandWhat it does
uv run invoke compose.contractCheck the resolved Compose model. Needs the Compose CLI, no daemon.
uv run invoke compose.lifecycleRun the Docker-backed lifecycle matrix against the loaded candidate.
uv run invoke compose.reclaimRemove the scanner's saved image archives, keeping the OCI layout.

compose.lifecycle needs uv run invoke image.smoke to have run first: it addresses the candidate by the configuration digest that build recorded, and refuses to build one itself, because a rebuild at the same source is a different digest.