Promoting Schemas Across Dev, Staging, and Prod with CI/CD
For platform and SRE teams running Apache Kafka, the schema is the contract between producers and consumers. When that contract is promoted by hand—copying definitions between environments or letting each registry evolve independently—you get silent data corruption, broken downstream pipelines, and incident calls. This guide describes an automated strategy for promoting schemas across development, staging, and production using a CI/CD pipeline, so every change is compatibility-checked against the target registry and registered only if it passes.
The approach builds on the broader Schema Registry & Connect Ecosystem. We use the Confluent Schema Registry REST API and Maven plugin inside a Git-driven pipeline, but the principles apply to any registry that exposes equivalent compatibility checks.
The Perils of Manual Schema Promotion
In a typical multi-environment Kafka deployment, each environment—dev, staging, prod—runs its own isolated Schema Registry. The common anti-pattern is to author a new Avro, Protobuf, or JSON Schema record in dev, test it, and then copy the raw schema file into the staging and prod registries by hand.
This introduces three concrete failure modes:
- Compatibility bypass. A schema that is compatible against
dev(where the subject may have only a few versions) can be incompatible against the longer, evolved version history of the same subject inprod. Manual copying never runs that check. - Schema ID divergence. Each registry assigns IDs independently. Schema ID 42 in staging may describe a different record than ID 42 in production, so you cannot safely replay or mirror data between environments for debugging or DR.
- No audit trail. Manual steps are unrepeatable and unreviewed, violating the infrastructure-as-code principle that every change be diffable and traceable.
A working command of compatibility types—BACKWARD, BACKWARD_TRANSITIVE, FORWARD, FORWARD_TRANSITIVE, FULL, FULL_TRANSITIVE, and NONE—is a prerequisite, since they decide exactly which prior versions a candidate is checked against. The non-transitive modes check only against the latest registered version; the _TRANSITIVE modes check against all previous versions. The choice is an operational decision, not a default to accept blindly:
| Mode | Checks against | Upgrade order it permits | Use when |
|---|---|---|---|
BACKWARD (default) |
latest version | consumers first, then producers | you control consumers and want to redeploy them ahead of producers |
BACKWARD_TRANSITIVE |
all versions | consumers first | consumers may read messages produced by any historical schema |
FORWARD |
latest version | producers first, then consumers | new data must be readable by old consumers you can’t redeploy yet |
FORWARD_TRANSITIVE |
all versions | producers first | old consumers must read data from any future schema |
FULL / FULL_TRANSITIVE |
latest / all | either order | producer and consumer deploys are decoupled and unordered |
NONE |
nothing | any | never on a production subject; checks are disabled |
The upgrade-order column is the part teams miss: a BACKWARD subject only stays safe if you actually deploy consumers before producers. Encode the intended mode per subject and treat it as part of the contract. See Schema Evolution and Compatibility in Confluent Schema Registry for the field-level rules behind each mode.
Architecting a Schema Promotion Pipeline
A robust pipeline treats the schema file in Git as the single source of truth. Its job is to take that artifact, validate it against the target environment’s history, and register it only if validation passes. A linear branch-to-environment mapping keeps promotion sequential: merge to develop registers in dev; merge to a release/* branch promotes to staging; merge to main promotes to production.
The gate relies on the registry’s compatibility-check endpoint. Rather than registering blindly, the pipeline first runs a dry-run compatibility check and registers only on success. Both steps are idempotent and safe to retry—re-registering an identical schema returns the existing version rather than creating a new one.
Two response behaviors decide whether the gate actually works:
- The compatibility endpoint returns HTTP 200 even when the schema is incompatible, carrying the result in the body as
{"is_compatible": false}. Gating on the HTTP status alone lets incompatible schemas through; you must parse the body. - Whitespace and field ordering count as a difference unless you normalize. Two semantically identical schemas that differ only in formatting register as distinct versions, polluting the history and breaking idempotency. Add
?normalize=trueon both the compatibility check and the registration so the registry compares canonical forms.
Maven Plugin Configuration
The Confluent kafka-schema-registry-maven-plugin drives both the check and the registration. The subjects element is a map from subject name to local schema file path; schema types are declared in a parallel schemaTypes map (defaulting to AVRO):
<plugin>
<groupId>io.confluent</groupId>
<artifactId>kafka-schema-registry-maven-plugin</artifactId>
<version>8.2.1</version>
<configuration>
<schemaRegistryUrls>
<param>${env.SCHEMA_REGISTRY_URL}</param>
</schemaRegistryUrls>
<subjects>
<transactions-value>src/main/avro/Transaction.avsc</transactions-value>
</subjects>
<schemaTypes>
<transactions-value>AVRO</transactions-value>
</schemaTypes>
</configuration>
</plugin>
SCHEMA_REGISTRY_URL is injected per environment, so the same source artifact is validated against each registry in turn. The plugin exposes the relevant goals as separate invocations so the pipeline can enforce the gate explicitly:
schema-registry:test-compatibility— checks the local schemas against the registry. It fails the build if any subject is incompatible.schema-registry:register— registers the schemas on the target registry.
Note that test-compatibility does not take a compatibilityLevels element; the level is whatever is configured on the subject (or globally) in the registry. To manage the level itself from CI, use the separate schema-registry:set-compatibility goal—run it once per subject as a deliberate change, not on every promotion, so the mode stays auditable.
REST API Compatibility Check and Registration
In polyglot shops where Java is not the primary language, you can skip the plugin and call the REST API directly. The key correctness point is parsing is_compatible rather than trusting the HTTP code:
#!/usr/bin/env bash
set -euo pipefail
SCHEMA_REGISTRY_URL="${SCHEMA_REGISTRY_URL:?SCHEMA_REGISTRY_URL must be set}"
SUBJECT="${SUBJECT_NAME:?SUBJECT_NAME must be set}"
SCHEMA_FILE="${1:?Usage: $0 <schema-file>}"
CT="Content-Type: application/vnd.schemaregistry.v1+json"
# Embed the schema as a JSON string inside the request body.
PAYLOAD=$(jq -n --rawfile schema "$SCHEMA_FILE" \
'{schema: $schema, schemaType: "AVRO"}')
# Dry-run compatibility check. Returns 200 with {"is_compatible": bool};
# ?verbose=true adds a "messages" array explaining any failure,
# ?normalize=true compares canonical forms so formatting isn't a diff.
RESULT=$(curl -sf \
-H "$CT" \
--data "$PAYLOAD" \
"$SCHEMA_REGISTRY_URL/compatibility/subjects/$SUBJECT/versions/latest?verbose=true&normalize=true")
if [ "$(jq -r '.is_compatible' <<<"$RESULT")" != "true" ]; then
echo "Incompatible schema for subject $SUBJECT:" >&2
jq -r '.messages // [] | .[]' <<<"$RESULT" >&2
exit 1
fi
# Compatible: register the new version (normalize so an identical
# schema is recognized as the existing version, not a new one).
curl -sf \
-H "$CT" \
--data "$PAYLOAD" \
"$SCHEMA_REGISTRY_URL/subjects/$SUBJECT/versions?normalize=true"
jq -n --rawfile reads the schema file and JSON-escapes it in one step, avoiding fragile shell quoting. curl -f makes transport-level errors (4xx/5xx) fail the script, while the is_compatible check catches the logical “compatible but returned 200” case. Only SCHEMA_REGISTRY_URL changes between dev, staging, and prod. The endpoints used here are documented in the Schema Registry API reference.
Implementing the CI/CD Workflow with GitHub Actions
The following workflow runs as a single job that selects its GitHub Environment from the branch name, so a push promotes to exactly one environment rather than fanning out across several in parallel. GitHub Environments also let you require manual approval before the production deployment runs.
name: Schema Promotion Pipeline
on:
push:
branches:
- develop
- 'release/*'
- main
jobs:
promote-schema:
runs-on: ubuntu-latest
environment:
name: >-
${{ github.ref_name == 'main' && 'production'
|| github.ref_name == 'develop' && 'dev'
|| 'staging' }}
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Test schema compatibility
run: mvn --batch-mode schema-registry:test-compatibility
env:
SCHEMA_REGISTRY_URL: ${{ secrets.SCHEMA_REGISTRY_URL }}
- name: Register schema
run: mvn --batch-mode schema-registry:register
env:
SCHEMA_REGISTRY_URL: ${{ secrets.SCHEMA_REGISTRY_URL }}
Two mechanisms enforce the gate. First, GitHub Actions runs each step only if the prior step succeeded, so register never runs unless test-compatibility passed—no explicit if: guard is needed. Second, configuring a required reviewer on the production environment forces a human approval before that branch’s job proceeds, giving you a human-in-the-loop checkpoint for the highest-risk changes.
One caveat worth designing around: because compatibility is checked against the live target registry, a check that passed on the PR can fail at merge time if another change registered a new version in between. Treat a failed register after a green check as a signal to re-run the check, not to retry blindly.
Managing Schema References and Complex Types
Real data contracts often span multiple, interrelated schemas. Avro records can reference other named schemas; Protobuf uses import. Promotion must respect ordering: a referenced schema has to be registered before any schema that depends on it.
POST /subjects/{subject}/versions accepts a references array so you can register a schema together with pointers to its already-registered dependencies. For Avro, each reference’s name is the fully-qualified name of the referenced schema (namespace + record name) exactly as it appears where the outer schema references it—not a file path. subject and version identify where that referenced schema is registered.
# Register an Envelope that references an already-registered Transaction schema.
curl -sf -X POST \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data '{
"schemaType": "AVRO",
"schema": "{\"type\":\"record\",\"name\":\"Envelope\",\"namespace\":\"io.example\",\"fields\":[{\"name\":\"payload\",\"type\":\"io.example.Transaction\"}]}",
"references": [
{
"name": "io.example.Transaction",
"subject": "transactions-value",
"version": 1
}
]
}' \
"$SCHEMA_REGISTRY_URL/subjects/envelope-value/versions"
The reference name (io.example.Transaction) matches the type used inside the Envelope record, which is how the registry resolves the dependency. In a pipeline, register leaf schemas (those with no references) first, then walk up the dependency tree—a topological sort over your schema directory. The Schema Registry Maven plugin handles reference wiring when you populate its references configuration.
References also constrain deletion: a version that is still referenced by another schema cannot be removed. Before any cleanup, query GET /subjects/{subject}/versions/{version}/referencedby, which returns the subject-version pairs that point at this schema. A non-empty result is your signal that the version is load-bearing and must stay.
Operational Safeguards and Rollback Strategies
Compatibility checks gate structural breakage, but they cannot catch semantic changes that break consumer business logic, so production promotions still need a tested rollback.
The right rollback is almost never deletion. Schema versions are immutable history, and clients cache schema IDs; removing a version that producers have already emitted can break consumers that later fetch that ID. The registry’s DELETE /subjects/{subject}/versions/{version} is a soft delete by default (the ID and metadata are retained and not reused); a hard delete requires a second call with ?permanent=true after the soft delete, and should be reserved for cleanup of never-consumed mistakes, not live rollback. (Separately, the force=true query parameter on the PUT /mode endpoint forces a registry mode change for DR—it is unrelated to deleting versions.)
The safe rollback is roll-forward: register a new version that reverts the breaking change compatibly. If version 5 dropped a required field, version 6 re-adds it as optional with a default. This neutralizes the break while preserving compatibility for everything already produced, and—critically—leaves the schema IDs that in-flight messages already carry intact.
To make roll-forward fast, snapshot the current production schema as a build artifact before promoting:
- name: Backup current production schema
run: |
curl -sf "$SCHEMA_REGISTRY_URL/subjects/$SUBJECT_NAME/versions/latest" \
| jq . > backup-schema.json
env:
SCHEMA_REGISTRY_URL: ${{ secrets.SCHEMA_REGISTRY_URL }}
SUBJECT_NAME: transactions-value
- uses: actions/upload-artifact@v4
with:
name: production-schema-backup
path: backup-schema.json
If a post-deployment smoke test fails, an on-call engineer pulls the backup artifact and uses its schema field to build the revert registration. Document this in the runbook and, ideally, wire it to a dedicated rollback workflow trigger so the recovery path is itself tested, not improvised under incident pressure.
Monitoring
Watch the health of the Schema Registry itself, not just the topics—it sits on the serialization path, so a registry outage stalls every producer and consumer that needs to resolve a new ID. The registry exposes JMX MBeans under kafka.schema.registry—for example the aggregate request rate is the request-rate attribute on the kafka.schema.registry:type=jersey-metrics MBean, and per-endpoint variants are prefixed by the endpoint name (such as subjects.list.request-latency-avg). Scrape these with a JMX exporter into Prometheus and alert on request error rate and latency.
The failure mode to detect early is a spike in registration error rate right after a promotion: it usually means producers across the fleet are retrying against a schema the registry rejected, which compatibility gating should have caught but a hand-run deploy bypassed. The Schema Registry monitoring guide lists the full set of MBeans.
Enforcing Organizational Policy with CI/CD Gates
Beyond structural compatibility, promotion often needs organizational sign-off: a change adding personally identifiable information (PII) may need security review; deprecating a field may need agreement from consuming teams.
Encode the mechanical parts as pipeline steps. For example, lint schema files for field names that look like PII and block the build unless they carry a data-classification tag (Confluent’s metadata tagging uses the confluent:tags property):
# Fail if a field name looks like PII but the file has no classification tags.
PII_PATTERNS="email|phone|ssn|credit_card"
if grep -Eq "\"name\"[[:space:]]*:[[:space:]]*\"[^\"]*(${PII_PATTERNS})[^\"]*\"" "$SCHEMA_FILE"; then
if ! grep -q '"confluent:tags"' "$SCHEMA_FILE"; then
echo "ERROR: potential PII field with no data-classification tag in $SCHEMA_FILE" >&2
exit 1
fi
fi
Combined with branch-protection rules and required reviewers, these checks form a defense-in-depth that keeps unsafe schemas out of the production registry. (Treat the regex as a backstop, not a guarantee—a structured walk of the schema’s fields is more robust than grep for anything beyond a smoke test.)
Incremental Adoption Path
Full automation is an evolution, not a big bang. Start by automating the compatibility check and registration for one low-risk topic, with normalize=true and body parsing in place from day one—those two are the difference between a gate that works and one that only looks like it does. Expand to all subjects, add reference handling and referencedby checks before any cleanup, then layer on policy gates. The end state is one where every schema change is reviewed, compatibility-tested, and auditable—the foundation reliable stream processing depends on.