How to Migrate Schemas to Apicurio v3

This guide shows you how to freeze schema registration in Apicurio Registry v2, export the schemas, import them into v3, verify the import, switch the production hostname to v3 and confirm that production traffic reaches v3.

Type

How-to guide

Goal

Serve every existing schema from Apicurio Registry v3 on the production hostname, with v2 kept on a legacy hostname for rollback.

Audience

Platform Operator with Helm access to the Axual Streaming release in the kafka namespace and the client secrets of the apicurio-migration and apicurio-api Keycloak clients.

When to use

Use this guide as the second stage of Upgrade Apicurio Registry from v2 to v3, after v3 runs alongside v2.

Prerequisites

Confirm the following before you begin.

Access and permissions required

You need the following access and permissions:

  • Helm access to the Axual Streaming release in the kafka namespace.

  • The client secret of the apicurio-migration Keycloak client.

  • The client secret of the platform’s apicurio-api Keycloak client.

Tools and versions required

You need the following tools:

  • helm and kubectl

  • curl and jq

Resources that must exist before starting

The following resources must already exist before you start:

Run Steps 2 to 6 in one sitting. Schema registration is blocked from Step 2 until the ingress switch in Step 6, typically for 30 to 45 minutes; reads keep working throughout.

The upgrade can’t run incrementally: if a schema is added to v2 after you export, empty v3 with Restore the Apicurio v3 topics and start again from Step 3.

Replace every <VALUE> placeholder and every example.com hostname with your actual value before running a command. [same as configured for your apicurio-registry] means copy the value from the matching key of your existing apicurio-registry block.

Step 1: Set shell variables

The CLI commands in this guide, and the script, reuse the shell variables below. Set them once in your shell:

# --- Hosts ---
EXISTING_APICURIO_HOST="apicurio.example.com"        # current production Apicurio v2 host
NEW_APICURIO_HOST="apicurio-v3.example.com"          # temporary v3 host, until the Step 6 cutover
LEGACY_APICURIO_HOST="apicurio-legacy.example.com"   # where v2 moves after cutover (kept for rollback)
APICURIO_KEYCLOAK_HOST="apicurio.example.com"        # Keycloak host (often the same host, under /auth)
APICURIO_REALM="apicurio"

# --- Migration client (holds the migration-admin role) ---
MIGRATION_CLIENT_ID="apicurio-migration"
MIGRATION_CLIENT_SECRET="<migration client secret from the Credentials tab>"

# --- Platform client (apicurio-api; used for the v3 import, keeps sr-admin throughout) ---
APICURIO_API_CLIENT_ID="apicurio-api"
APICURIO_API_CLIENT_SECRET="<apicurio-api client secret>"

Step 2: Block schema registration in v2

Apicurio v2 has no global read-only switch, so freeze registration with two changes to the v2 deployment that keep reads working:

  • Remap the developer roles (in apicurio-registry.env) so no token resolves to a developer role: sr-developer and sr-admin match nothing, and only the migration client (holding migration-admin) can still register subjects.

  • Disable admin-override (in apicurio-registry.config). The chart enables it with role sr-admin, and Apicurio evaluates it before role-based access control (RBAC), so any sr-admin token, including the platform’s apicurio-api, would otherwise bypass the freeze.

axual-streaming:
  apicurio-registry:
    config:
      # The chart sets registry.auth.admin-override.enabled=true (role: sr-admin); turn it off here.
      registry.auth.admin-override.enabled: "false"
    env:
      # No regular client resolves to a developer role any more (sr-developer / sr-admin match nothing);
      # only the dedicated `apicurio-migration` client (holding the `migration-admin` role) can still register subjects.
      - name: REGISTRY_AUTH_ROLES_DEVELOPER
        value: "sr-developer-frozen"   # no token carries this name, so apps registering subjects are denied
      - name: REGISTRY_AUTH_ROLES_ADMIN
        value: "migration-admin"       # the admin role during the freeze; held ONLY by the migration client
      # Keep authenticated readers (Platform Manager, Topic Browse, your apps) able to read subjects during the freeze.
      - name: REGISTRY_AUTH_AUTHENTICATED_READS_ENABLED
        value: "true"

Disable admin-override in config:, not with the REGISTRY_AUTH_ADMIN_OVERRIDE_ENABLED environment variable. The chart sets this property in its config block, and the environment variable doesn’t override it: the property name contains a dash (admin-override), which the environment-variable mapping doesn’t match, so the variable is silently ignored. The role remaps above have no dash, so they work through env.

Apply both with a helm upgrade of the Streaming release, using the same command as in Step 2: Deploy Apicurio v3. Only the v2 registry pod restarts, and reads resume within seconds. Every client, including the platform’s apicurio-api (sr-admin), is now read-only, and only the migration-admin client can register or export.

The freeze doesn’t affect anonymous read access, which only REGISTRY_AUTH_ANONYMOUS_READS_ENABLED (registry.auth.anonymous-read-access.enabled) controls. Apicurio evaluates anonymous read before RBAC, so the role remap can’t disable it. With anonymous read enabled (as in production), both anonymous and authenticated reads keep working throughout; only subject registration is blocked.

Step 3: Export schemas from v2

Use the dedicated migration client ($MIGRATION_CLIENT_ID), not the platform’s apicurio-api client, which must stay frozen. Get a token for it, record how many artifacts v2 holds so you can match the count on v3 in Step 5, then export all schemas and metadata:

# Get an access token from the v2 Keycloak.
# Use the migration client: it holds migration-admin, now the only admin role,
# so it keeps register/export access while every other client is frozen to read-only.
TOKEN=$(curl -skv -X POST \
  "https://${APICURIO_KEYCLOAK_HOST}/auth/realms/${APICURIO_REALM}/protocol/openid-connect/token" \
  -d "grant_type=client_credentials&client_id=${MIGRATION_CLIENT_ID}&client_secret=${MIGRATION_CLIENT_SECRET}" \
  | jq -r '.access_token')

# Count the artifacts currently in v2; record this number to verify the v3 import in Step 5.
curl -skv \
  "https://${EXISTING_APICURIO_HOST}/apis/registry/v2/search/artifacts" \
  -H "Authorization: Bearer $TOKEN" | jq '.count'

# Export all schemas and metadata.
curl -skv \
  "https://${EXISTING_APICURIO_HOST}/apis/registry/v2/admin/export" \
  -H "Authorization: Bearer $TOKEN" \
  -o apicurio-export.zip
The export, and the Step 4 import, can take several minutes for large registries. Check the request timeout on your Ingress (or OpenShift Route) first and raise it, for example to 600 seconds, so the connection isn’t dropped mid-transfer. For nginx, the annotations are nginx.ingress.kubernetes.io/proxy-read-timeout and proxy-send-timeout.

Verify the export file isn’t empty:

ls -lh apicurio-export.zip   # should be > 0 bytes

Step 4: Import schemas into v3

Import the export file into v3 with the platform’s apicurio-api client.

The v3 registry must be empty for the import to succeed. If a previous attempt left data behind, or you need to start over, empty v3 first with Restore the Apicurio v3 topics.

Import is an admin operation on v3, which isn’t frozen, so use the platform’s apicurio-api client ($APICURIO_API_CLIENT_ID and $APICURIO_API_CLIENT_SECRET), which keeps its normal sr-admin role throughout. This is a different client from the $MIGRATION_CLIENT_ID used for the export in Step 3. Fetch the token from the existing v2 Keycloak, which v3 still uses at this stage.

# Get an access token for the platform apicurio-api client (from the existing v2 Keycloak).
TOKEN=$(curl -skv -X POST \
  "https://${APICURIO_KEYCLOAK_HOST}/auth/realms/${APICURIO_REALM}/protocol/openid-connect/token" \
  -d "grant_type=client_credentials&client_id=${APICURIO_API_CLIENT_ID}&client_secret=${APICURIO_API_CLIENT_SECRET}" \
  | jq -r '.access_token')

# Import into v3.
curl -skv \
  "https://${NEW_APICURIO_HOST}/apis/registry/v3/admin/import" \
  -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/zip" \
  --data-binary @apicurio-export.zip

Step 5: Verify the import

Check that artifacts were imported with their versions, not only their metadata, and that the count matches the v2 artifact count you recorded in Step 3:

# Count imported artifacts; must match the v2 count from Step 3.
curl -skv \
  "https://${NEW_APICURIO_HOST}/apis/registry/v3/search/artifacts?limit=5" \
  -H "Authorization: Bearer $TOKEN" | jq '.count'

# Pick one artifact and confirm it has versions.
ARTIFACT_ID=$(curl -skv \
  "https://${NEW_APICURIO_HOST}/apis/registry/v3/search/artifacts?limit=1" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.artifacts[0].artifactId')

curl -skv \
  "https://${NEW_APICURIO_HOST}/apis/registry/v3/groups/default/artifacts/${ARTIFACT_ID}/versions" \
  -H "Authorization: Bearer $TOKEN" | jq '.count'   # must be > 0

If the counts differ or an artifact has no versions, see How to Troubleshoot the Apicurio v3 Upgrade before you switch ingress.

Step 6: Switch ingress to v3

Move the production hostname from v2 to v3, and v2 to a legacy hostname.

Don’t disable v2 yet. v3 depends on the v2 Keycloak for authentication until you complete How to Remove Apicurio v2.

Update v3 to the production URL, the EXISTING_APICURIO_HOST your v2 deployment used until now. Re-point registryApiUrl, webRedirectUrl, both ingress hosts and their tls.hosts away from the temporary NEW_APICURIO_HOST:

axual-streaming:
  apicurio-registry-v3:
    ui:
      config:
        # From NEW_APICURIO_HOST to EXISTING_APICURIO_HOST
        registryApiUrl: "https://apicurio.example.com/apis/registry/v3"
    security:
      authentication:
        keycloak:
          # From NEW_APICURIO_HOST to EXISTING_APICURIO_HOST
          webRedirectUrl: "https://apicurio.example.com"
    ingress:
      backend:
        host: "apicurio.example.com"
        tls:
          - secretName: [same as configured for your apicurio-registry]
            hosts:
              - "apicurio.example.com"
      ui:
        host: "apicurio.example.com"
        tls:
          - secretName: [same as configured for your apicurio-registry]
            hosts:
              - "apicurio.example.com"

Move v2 to a legacy hostname (LEGACY_APICURIO_HOST) so it stays reachable for a rollback. Re-point its webRedirectUrl, ingress host and tls.hosts too; otherwise the v2 UI redirect and certificate still reference the production hostname that v3 now serves:

axual-streaming:
  apicurio-registry:
    security:
      authentication:
        keycloak:
          # Point v2's redirect at its new legacy hostname (LEGACY_APICURIO_HOST)
          webRedirectUrl: "https://apicurio-legacy.example.com"
    ingress:
      hosts:
        - host: "apicurio-legacy.example.com"
      tls:
        - secretName: [same as configured for your apicurio-registry]
          hosts:
            - "apicurio-legacy.example.com"

If you use the Auth Proxy, cut its ingress over at the same time: give the v3 Auth Proxy the production auth-proxy hostname, and move the v2 Auth Proxy ingress (the apicurio-auth ingress) to a legacy hostname so it stays reachable for a rollback.

axual-streaming:
  apicurio-registry-v3:
    authProxy:
      ingress:
        hosts:
          - host: "apicurio-auth-proxy.example.com"
            paths:
              - path: "/apis"
                pathType: "Prefix"
        tls:
          - secretName: [same as configured for your apicurio-registry]
            hosts:
              - "apicurio-auth-proxy.example.com"
Move the v2 Auth Proxy ingress to a legacy hostname with the same authProxy ingress key you configured in your v2 values (for example apicurio-auth-proxy-legacy.example.com), mirroring the v2 registry move above. Skip this if you don’t run the Auth Proxy.

Apply all changes with a helm upgrade, using the same command as in Step 2: Deploy Apicurio v3. Schema registration resumes as soon as the production hostname reaches v3, because the freeze applies only to v2.

Your Instance Clusters need no change to their Schema Registry URL. It keeps the same hostname and the /apis/registry/v2 path; this step repoints that hostname from v2 to v3, and Apicurio v3 keeps serving the v2 API. The Axual Platform switches to the v3 API in 2026.4.

Step 7: Verify production traffic reaches v3

Check that the production hostname now reaches v3 on both API paths, and that it holds the same number of artifacts as v2 did. Only v3 serves the /apis/registry/v3 path, so an answer there proves the cutover; the /apis/registry/v2 path is the one your Instance Clusters and applications keep using.

# Get a fresh token for the platform apicurio-api client.
TOKEN=$(curl -skS --fail-with-body -X POST \
  "https://${APICURIO_KEYCLOAK_HOST}/auth/realms/${APICURIO_REALM}/protocol/openid-connect/token" \
  -d "grant_type=client_credentials&client_id=${APICURIO_API_CLIENT_ID}&client_secret=${APICURIO_API_CLIENT_SECRET}" \
  | jq -r '.access_token')

# v3 API on the production hostname; must match the v2 count from Step 3.
curl -skS --fail-with-body \
  "https://${EXISTING_APICURIO_HOST}/apis/registry/v3/search/artifacts?limit=1" \
  -H "Authorization: Bearer $TOKEN" | jq '.count'

# v2 API on the production hostname, now served by v3; must print the same count.
curl -skS --fail-with-body \
  "https://${EXISTING_APICURIO_HOST}/apis/registry/v2/search/artifacts?limit=1" \
  -H "Authorization: Bearer $TOKEN" | jq '.count'

Both commands print the artifact count you recorded in Step 3. Then open https://apicurio.example.com in a browser and confirm the v3 web console lists your artifacts. If either check fails, see How to Roll Back the Apicurio v3 Upgrade.

Run steps 3 to 5 with a script

The script below runs the CLI part of this guide in one pass: the export from v2 (Step 3), the import into v3 (Step 4) and the count comparison from Step 5, with the same variables as Step 1. It doesn’t perform the Helm changes: apply the registration freeze (Step 2) before running it, and the ingress switch (Step 6) after.

The registration freeze must already be in place when you run this. The script compares $V2_COUNT and $V3_COUNT and warns if they differ, but it can’t detect schemas registered in v2 after the export; the freeze prevents that.
#!/usr/bin/env bash
set -euo pipefail

# 1. Configure variables
EXISTING_APICURIO_HOST="apicurio.example.com"        # current production Apicurio v2 host
NEW_APICURIO_HOST="apicurio-v3.example.com"          # temporary v3 host, until the Step 6 cutover
APICURIO_KEYCLOAK_HOST="apicurio.example.com"        # Keycloak host (often the same host, under /auth)
APICURIO_REALM="apicurio"
MIGRATION_CLIENT_ID="apicurio-migration"             # holds migration-admin; keeps export access during the freeze
MIGRATION_CLIENT_SECRET="<migration client secret>"
APICURIO_API_CLIENT_ID="apicurio-api"                # platform client; keeps sr-admin for the v3 import
APICURIO_API_CLIENT_SECRET="<apicurio-api client secret>"

# Helper: fetch a client_credentials token from the v2 Keycloak.
get_token() {  # get_token <client_id> <client_secret>
  curl -skv -X POST \
    "https://${APICURIO_KEYCLOAK_HOST}/auth/realms/${APICURIO_REALM}/protocol/openid-connect/token" \
    -d "grant_type=client_credentials&client_id=$1&client_secret=$2" \
    | jq -r '.access_token'
}

# 2. Get an access token for v2 (migration client keeps register/export during the freeze)
TOKEN=$(get_token "$MIGRATION_CLIENT_ID" "$MIGRATION_CLIENT_SECRET")

# 3. Export schemas from v2
curl -skv "https://${EXISTING_APICURIO_HOST}/apis/registry/v2/admin/export" \
  -H "Authorization: Bearer $TOKEN" -o apicurio-export.zip
ls -lh apicurio-export.zip   # should be > 0 bytes

# 4. Count the artifacts in v2
V2_COUNT=$(curl -skv "https://${EXISTING_APICURIO_HOST}/apis/registry/v2/search/artifacts" \
  -H "Authorization: Bearer $TOKEN" | jq '.count')
echo "v2 artifacts: $V2_COUNT"

# 5. Import schemas into v3 (apicurio-api client; v3 is not frozen)
TOKEN=$(get_token "$APICURIO_API_CLIENT_ID" "$APICURIO_API_CLIENT_SECRET")
curl -skv "https://${NEW_APICURIO_HOST}/apis/registry/v3/admin/import" \
  -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/zip" \
  --data-binary @apicurio-export.zip

# 6. Count the artifacts in v3 and compare against v2
V3_COUNT=$(curl -skv "https://${NEW_APICURIO_HOST}/apis/registry/v3/search/artifacts" \
  -H "Authorization: Bearer $TOKEN" | jq '.count')
echo "v2 artifacts: $V2_COUNT, v3 artifacts: $V3_COUNT"
[ "$V2_COUNT" = "$V3_COUNT" ] && echo "OK: counts match" || echo "WARNING: counts differ"

Restore the Apicurio v3 topics

To discard a failed import and run Step 4 again, the v3 store must be empty. Restore the v3 Kafka topics:

  1. Delete the three v3 Kafka topics (kafka.journalTopic, kafka.snapshotsTopic, kafka.eventsTopic).

  2. Restart the v3 pod (for example kubectl rollout restart deploy/<apicurio-registry-v3-deployment> -n kafka). On startup the chart’s init container recreates the three topics, and their ACLs (Access Control Lists), empty.

  3. Re-run the import from Step 4.

This restores only the v3 registry store and doesn’t touch v2. v2 keeps serving reads throughout.

Next steps

Keep v2 on its legacy hostname for your rollback window. Once you’re confident in v3, continue with How to Remove Apicurio v2.