Upgrade to Apicurio Registry v3

This page describes how to upgrade the Apicurio Registry server from v2 to v3 on Axual Streaming 2.0.0 (Axual Platform 2026.2). It assumes you have already upgraded Axual Streaming to 2.0.0 (your existing Apicurio v2 keeps running unchanged) — see Upgrade Axual Streaming to 2.0.0.

Axual Streaming 2.0.0 ships both Apicurio Registry versions side by side:

  • Apicurio Registry v2 — the apicurio-registry 3.1.11 chart (Apicurio Registry 2.6.13.Ax1), keyed as apicurio-registry, toggled with global.apicurio.enabled (enabled by default).

  • Apicurio Registry v3 — the apicurio-registry-v3 0.1.0 chart (Apicurio Registry 3.3.0.Ax1), keyed as apicurio-registry-v3, toggled with global.apicurio-registry-v3.enabled (enable it to upgrade).

Both versions ship together so you can run them side by side, move your schemas, switch traffic to v3 and then retire v2 — with no impact on client apps.

Apicurio v2 is fully supported in 2026.2, but it will be removed in 2026.3. The upgrade deadline depends on your release track:

  • Non-LTS users: you must complete the upgrade before you move to 2026.3.

  • LTS users: you can continue running v2 until you upgrade to 2027.2 (the next LTS), at which point v2 will be no longer available.

Apicurio v3 is a major version with breaking changes. The data Apicurio stores in Kafka is not compatible between v2 and v3, so this upgrade is performed as an export from v2 / import into v3 — not as an in-place version bump.

Expected downtime
  • Reads: near-zero.

  • Writes: schema registration to v2 must be blocked during export and import — typically 30–45 minutes, until ingress is switched to v3.

This upgrade changes only the Apicurio Registry server. Your applications do not need to change: the v2 Apicurio Registry client libraries (for example, the Java client and SerDes, 2.x) remain supported against the v3 registry. You can upgrade the registry now and move your application clients to v3 later, at your own pace — see Version compatibility across releases for the support timeline.

Version compatibility across releases

Apicurio v3 keeps serving the v2 API alongside its native v3 API. As a result, the registry server, the Axual governance plane (Platform Manager and Topic Browse), and your applications each move from the v2 API to the v3 API independently, on different schedules:

Platform version Apicurio server Platform Manager and Topic Browse API Apps API

2026.1

v2

v2

v2

2026.2 (LTS)

v3

v2

v2 (stable) or v3 (beta)

2026.3

v3

v3

v2 or v3 (both stable)

2026.4

v3

v3

v2 or v3 (both stable)

2027.1

v3

v3

v2 or v3 (both stable)

2027.2 (LTS)

v3

v3

v2 (deprecated) or v3 (stable)

In 2026.2 you run the v3 server, but Platform Manager and Topic Browse keep calling its v2 API (keep the /apis/registry/v2 path in the Schema Registry URL), and your applications keep using their v2 clients.

Confluent-compatible (ccompat) endpoint

Applications that use the Confluent-compatible API are the one client-side exception. Apicurio v3 removes the /apis/ccompat/v6 endpoint and serves only /apis/ccompat/v7, so these clients must change their Schema Registry URL from /apis/ccompat/v6 to /apis/ccompat/v7.

Apicurio v2 already serves /apis/ccompat/v7 too, so make this change before the ingress cutover (Step 6): the v7 URL works against both versions, so moving the server to v3 is then seamless for them. This is unrelated to the native registry API, which v3 keeps serving unchanged at /apis/registry/v2.

What changed in Apicurio v3

Area Apicurio v2 Apicurio v3

Configuration property prefix

registry.*

apicurio.*

Environment variables

REGISTRY_*

APICURIO_*

Container image

apicurio-registry-kafkasql (Axual fork)

apicurio/apicurio-registry (official upstream)

Web UI

Bundled in the registry container

Separate container (apicurio-registry-ui)

Ingress

Single ingress block

Split into ingress.backend (API) and ingress.ui (UI)

Kafka storage

A single compacted topic

Three separate topics (journal, snapshots, events)

Consumer group prefix

apicurio-registry-

apicurio-

Kafka topics

Where v2 used a single compacted topic, v3 uses three topics. All three are created automatically by the chart’s init container — together with the ACLs for the Apicurio principal — and their names are configurable:

Values key Purpose

kafka.journalTopic

Schema data journal

kafka.snapshotsTopic

Periodic data snapshots

kafka.eventsTopic

Change-event notifications

The default consumer group prefix also changes from apicurio-registry- to apicurio-; this is handled automatically by the init container.

Prerequisites

Create the migration client in Keycloak

The apicurio-migration client is the only principal that retains register and export access on v2 during the registration freeze (Step 3). It needs the migration-admin role, which becomes the sole admin role on v2 while every other client — including the platform’s apicurio-api — is read-only.

Log in to the Keycloak Admin Console for the realm defined in your Apicurio values under security.authentication.keycloak.realm.

  1. Create the migration-admin realm role: Navigate to Realm rolesCreate role, enter migration-admin as the role name, and save.

  2. Create the apicurio-migration client: Navigate to ClientsCreate client. Set the Client ID to apicurio-migration, then on the Capability config step apply these settings:

    Setting Value

    Client authentication

    On

    Authorization

    Off

    Standard flow

    unchecked

    Implicit flow

    unchecked

    Direct access grants

    unchecked

    Service accounts roles

    checked

    OAuth 2.0 Device Authorization Grant

    unchecked

    OIDC CIBA Grant

    unchecked

    Save the client and copy the client secret from the Credentials tab — you will need it in Step 3.

  3. Assign the role to the service account: Open the apicurio-migration client, go to the Service account roles tab and click Assign role. In the filter dropdown (top-left of the dialog), switch from Filter by clients to Filter by realm roles, then select migration-admin and click Assign.

Step 1 — Prepare the Apicurio v3 values

Your Apicurio v2 keeps running unchanged under its apicurio-registry: block. Add a new apicurio-registry-v3: configuration block alongside it by working through the sections below.

Do not pin image.tag in any of the blocks below — the chart already pins the correct Apicurio version.

1.1 — Enable Apicurio v3 and disable bundled Keycloak

Enable Apicurio v3 in the global block. Disable the bundled Keycloak instances — v3 reuses the existing v2 Keycloak throughout the upgrade.

global:
  apicurio:
    enabled: true              # your existing Apicurio v2, still running
  apicurio-registry-v3:
    enabled: true              # enable Apicurio v3 alongside v2

axual-streaming:
  apicurio-registry-v3:
    imagePullSecrets:
      - name: [same as configured for your apicurio-registry]

    apicurioKeycloak:
      enabled: false   # reuse the v2 Keycloak — do not deploy a new one
    apicurioKeycloakMysql:
      enabled: false

1.2 — UI URL

Set the public HTTPS URL the browser uses to reach the v3 API. Use a temporary hostname for now — you will switch to the production hostname in Step 6.

axual-streaming:
  apicurio-registry-v3:
    ui:
      config:
        registryApiUrl: "https://apicurio-v3.example.com/apis/registry/v3"
registryApiUrl must be publicly reachable from the browser — do not use localhost.

1.3 — Kafka

Replace <tenant> and <instance> with the values for your environment.

axual-streaming:
  apicurio-registry-v3:
    kafka:
      bootstrapServers: [same as configured for your apicurio-registry]
      journalTopic: "_<tenant>-<instance>-kafkasql-journal"
      snapshotsTopic: "_<tenant>-<instance>-kafkasql-snapshots"
      eventsTopic: "_<tenant>-<instance>-registry-events"

1.4 — TLS

Reuse the same TLS secret names as your existing Apicurio v2 deployment.

axual-streaming:
  apicurio-registry-v3:
    tls:
      clientKeypairSecretName: [same as configured for your apicurio-registry]
      serverKeypairSecretName: [same as configured for your apicurio-registry]
      truststoreCaSecretName: [same as configured for your apicurio-registry]

1.5 — Kafka init container

The init container creates the three Kafka topics and the ACLs for the Apicurio principal. Reuse the same broker TLS secret names as your Apicurio v2 deployment.

axual-streaming:
  apicurio-registry-v3:
    kafkaInitContainer:
      apicurioPrincipal: [same as configured for your apicurio-registry]
      replicationFactor: [same as configured for your apicurio-registry]
      minIsr: [same as configured for your apicurio-registry]
      tls:
        keypairSecretName: [same as configured for your apicurio-registry]
        keypairSecretKeyName: [same as configured for your apicurio-registry]
        keypairSecretCertName: [same as configured for your apicurio-registry]
        truststoreCaSecretName: [same as configured for your apicurio-registry]
        truststoreCaSecretCertName: [same as configured for your apicurio-registry]

1.6 — Security

Copy the security block from your v2 values as-is. The only value that must differ is webRedirectUrl — point it to the temporary v3 hostname for now; you will update it to the production hostname in Step 6.

axual-streaming:
  apicurio-registry-v3:
    security:
      authentication:
        enabled: true
        basicAuthEnabled: true
        keycloak:
          authUrl: [same as configured for your apicurio-registry]
          realm: [same as configured for your apicurio-registry]
          webClientId: [same as configured for your apicurio-registry]
          webRedirectUrl: "https://apicurio-v3.example.com"   # point to v3, not v2

1.7 — Ingress

Where v2 had a single ingress block with a hosts array, v3 splits it into ingress.backend (the API) and ingress.ui (the web console). Both blocks accept the same className, annotations, and tls fields you already have on the v2 ingress, and both route to the same Apicurio Service — typically <release-name>-apicurio-registry-v3 — on different ports and paths:

Ingress (values key) Service name Service port Path

ingress.backend

<release-name>-apicurio-registry-v3

20500 (http)

/apis

ingress.ui

<release-name>-apicurio-registry-v3

20501 (ui-http)

/

If you front Apicurio with an external ingress or gateway rather than the chart’s ingress resources, see the Ingress routing reference for the full service/port/path mapping (including the Auth Proxy).

Use a temporary hostname here — you will cut over to the production hostname in Step 6.

axual-streaming:
  apicurio-registry-v3:
    ingress:
      backend:
        enabled: true
        className: [same as configured for your apicurio-registry]
        annotations: [same as configured for your apicurio-registry]
        host: "apicurio-v3.example.com"
        tls:
          - secretName: [same as configured for your apicurio-registry]
            hosts:
              - "apicurio-v3.example.com"
      ui:
        enabled: true
        className: [same as configured for your apicurio-registry]
        annotations: [same as configured for your apicurio-registry]
        host: "apicurio-v3.example.com"
        tls:
          - secretName: [same as configured for your apicurio-registry]
            hosts:
              - "apicurio-v3.example.com"
The external-dns annotations belong on backend only — both backend and UI share the same hostname, so a single DNS registration is enough. The UI does not need to re-register the record.

1.8 — Auth Proxy (optional)

Skip this section if you do not use the Auth Proxy sidecar.

The Auth Proxy is disabled by default (authProxy.enabled: false). When enabled, it runs as a sidecar and validates JWT tokens before forwarding requests to the Apicurio backend.

If you are adapting your v2 authProxy values, update these three fields — in v2 the Apicurio backend was on port 8080, so the proxy could sit at 8081; in v3 the backend moved to 8081, so these must change:

Value v2 v3

authProxy.port

8081

8082

authProxy.config.server.port

8081

8082

authProxy.config.auth-proxy.backend-service

http://localhost:8080

http://localhost:8081

The Auth Proxy ingress cannot share a hostname and path with ingress.backend or ingress.ui — Nginx rejects duplicate rules. Give the Auth Proxy its own hostname and set ui.config.registryApiUrl (section 1.2) to the direct ingress.backend hostname, so the UI — which authenticates via Apicurio Keycloak, not the Auth Proxy — bypasses it. ingressWithoutAuthProxy has been removed in v3; the split ingress.backend + ingress.ui design covers the same use case.

axual-streaming:
  apicurio-registry-v3:
    authProxy:
      enabled: true
      secrets:
        auth-proxy:
          client-secret-salt: not-too-salty
          clientSecretAlgorithm: HmacSHA256
      config:
        auth-proxy:
          valid-issuer-uri: [same as configured for your apicurio-registry]
          jwks-endpoint-uri: [same as configured for your apicurio-registry]
          client-id: [same as configured for your apicurio-registry]
          check-audience: [same as configured for your apicurio-registry]
      ingress:
        enabled: true
        className: [same as configured for your apicurio-registry]
        annotations: [same as configured for your apicurio-registry]
        hosts:
          - host: "apicurio-auth-proxy.example.com"
            paths:
              - path: "/apis"
                pathType: "Prefix"
        tls:
          - secretName: "<same TLS secret as your other ingresses>"
            hosts:
              - "apicurio-auth-proxy.example.com"

Step 2 — Deploy Apicurio v3 alongside v2

Apply the updated values with a Helm upgrade of your existing Streaming release:

helm upgrade --install streaming oci://registry.axual.io/axual-charts/axual-streaming --version=2.0.0 -n kafka -f values.yaml

Wait for all apicurio-registry-v3 pods to become ready before continuing.

Step 3 — Block schema registration in v2 and export schemas

Block new schema registrations in v2 for the duration of the export and import. The upgrade cannot be run incrementally: if a schema is added to v2 after you export, you must delete the v3 journal topic and start the upgrade over (see Troubleshooting).

3.1 — Set shell variables

The CLI commands in the rest of this page reuse the shell variables below — the same names are used consistently throughout, and in the end-to-end script at the end. 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 (created in the prerequisites; 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>"

3.2 — Block schema registration in v2

Apicurio v2 has no global way to make the Registry read-only.

We need two changes on the Apicurio v2 deployment to freeze all subjects registration while keeping subjects read:

  • 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 your dedicated migration client (holding migration-admin) keeps developer role and rights to register new subjects.

  • Disable admin-override (in apicurio-registry.config) — the chart enables it with role sr-admin, and because it is evaluated before RBAC, any sr-admin token (including the platform’s apicurio-api) would otherwise bypass the freeze.

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 `migration-admin` role) keeps reading/registering subjects access.
    - name: REGISTRY_AUTH_ROLES_DEVELOPER
      value: "sr-developer-frozen"   # no token carries this name → apps registering subjects 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 env var does not override it — the property name contains a dash (admin-override), which the environment-variable mapping does not match, so the env var is silently ignored. (The role remaps above have no dash, so they work via env.)

Apply both with a helm upgrade of the Streaming release; only the v2 registry pod restarts, and reads resume within seconds. With these in place, every client — including the platform’s apicurio-api (sr-admin) — is read-only, and only the migration-admin client can register or export.

This freeze does not affect anonymous read access. Anonymous read is governed only by REGISTRY_AUTH_ANONYMOUS_READS_ENABLED (registry.auth.anonymous-read-access.enabled), which the freeze leaves untouched, and Apicurio evaluates anonymous read before RBAC — so the role remap cannot disable it. With anonymous read enabled (as in production), both anonymous and authenticated reads keep working throughout; only subject registration is blocked.

3.3 — Export schemas from v2

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

# Get an access token from the v2 Keycloak.
# Use the dedicated migration client that holds the migration-admin role — 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 — for nginx, the relevant annotations are nginx.ingress.kubernetes.io/proxy-read-timeout and proxy-send-timeout — and raise it (for example to 600 seconds) so the connection is not dropped mid-transfer.

Verify the export file is not empty:

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

Step 4 — Import schemas into v3

The v3 registry must be empty for the import to succeed. If a previous attempt left data behind — or you need to start over — restore the v3 Kafka topics first: see Restore Apicurio v3 topics in the Rollback section.

Import is an admin operation on v3 — which is not frozen — so use the platform’s apicurio-api client ($APICURIO_API_CLIENT_ID / $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.3. The token is still fetched from the existing v2 Keycloak, since v3 reuses it 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 just metadata), and that the count matches the v2 artifact count you recorded in Step 3.3:

# Count imported artifacts — must match the v2 count from Step 3.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

Step 6 — Switch ingress to v3

Do not disable v2 yet. v3 still depends on the v2 Keycloak for authentication until Step 7 is complete.

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

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 in case you need to roll back. Re-point its webRedirectUrl, ingress host and tls.hosts too — otherwise the v2 UI redirect and certificate still reference the production hostname now served by v3:

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: [ LEGACY_APICURIO_HOST ]
    tls:
      - secretName: [same as configured for your apicurio-registry]
        hosts:
          - [ LEGACY_APICURIO_HOST ]

If you use the Auth Proxy (section 1.8), 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 rollback.

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 using the same authProxy ingress key you configured in your v2 values (for example apicurio-auth-proxy-legacy.example.com), mirroring the v2 registry legacy move above. Skip this if you do not run the Auth Proxy.

Apply all changes with a helm upgrade (same command as Step 2).

Your Instance Clusters need no Schema Registry reconfiguration. The Schema Registry URL keeps the same hostname and the /apis/registry/v2 path; Step 6 simply 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.3.)

Step 7 — Keycloak handoff and disable v2

Before you disable v2, v3 must take over authentication — disabling v2 removes everything the v2 release owns. The chart provides Keycloak as two independent pieces: its database (apicurioKeycloakMysql) and the Keycloak server (apicurioKeycloak). Move each piece from v2 to v3 only if it is the bundled one; if a piece is externalized, v3 points at the same external instance, and you skip that move.

Whatever the v2 release owns is deleted when you disable v2 — including the bundled apicurioKeycloakMysql database (realm, users, client secrets, sr-admin/sr-readonly grants). Complete the applicable moves below and verify v3 authentication before disabling v2.

Move the Keycloak MySQL from v2 to v3 (optional — skip if the database is externalized)

If your Keycloak database is externalized (for example Azure Database for MySQL), skip this sub-step. It is not owned by either release, and v3’s Keycloak will point at the same database in the next sub-step.

The bundled apicurioKeycloakMysql belongs to the v2 release, so stand up a v3-owned MySQL and copy the data into it (do not disable v2 yet):

  1. Enable a v3-owned MySQL on the v3 release:

    apicurio-registry-v3:
      apicurioKeycloakMysql:
        enabled: true
        auth:
          database: "apicurio-kc-db"
          username: "keycloak"
          # use the same passwords as your existing v2 Keycloak database
  2. Copy the Keycloak data from the v2 database into the v3 database — for example with mysqldump, while both MySQL pods are running:

    # Dump the v2 Keycloak database
    kubectl exec -n kafka <v2-mysql-pod> -- \
      mysqldump -u root -p"<root-password>" apicurio-kc-db > apicurio-kc-db.sql
    
    # Restore it into the v3 Keycloak database
    kubectl exec -i -n kafka <v3-mysql-pod> -- \
      mysql -u root -p"<root-password>" apicurio-kc-db < apicurio-kc-db.sql

Move Keycloak from v2 to v3 (optional — skip if Keycloak is externalized)

If Keycloak is externalized — deployed by a separate chart/release with apicurioKeycloak.enabled: false in the Apicurio chart — skip this sub-step. Apicurio v2 and v3 both point at the same external Keycloak, so there is nothing to move; go straight to Disable Apicurio v2.

The bundled apicurioKeycloak belongs to the v2 release, so enable it on the v3 release, pointed at the v3-owned MySQL from the previous sub-step (or at your external database if only the MySQL is externalized):

apicurio-registry-v3:
  apicurioKeycloak:
    enabled: true
    realm: "apicurio"
    args: ['start']
    database:
      vendor: "mysql"
      hostname: "<keycloak-db-hostname>"   # the v3-owned MySQL from the previous sub-step, or your external database
      database: "apicurio-kc-db"
      username: "keycloak"

Restart the v3 Keycloak pod and verify you can log in with an sr-admin user and that the realm, clients and role grants are present.

Disable Apicurio v2

Only after v3 authentication is verified, disable the v2 registry so you no longer run two registries side by side:

global:
  apicurio:
    enabled: false

Rollback

If something goes wrong after the ingress switch, roll back by pointing ingress back to v2:

apicurio-registry-v3:
  ingress:
    backend:
      enabled: false
    ui:
      enabled: false

apicurio-registry:
  ingress:
    hosts:
      - host: "apicurio.example.com"

Also undo the Step 3 registration freeze on the v2 release so it accepts schema registrations again: remove the REGISTRY_AUTH_ROLES_DEVELOPER / REGISTRY_AUTH_ROLES_ADMIN remaps and REGISTRY_AUTH_AUTHENTICATED_READS_ENABLED from apicurio-registry.env, and remove the registry.auth.admin-override.enabled: "false" line from apicurio-registry.config (restoring the chart default), then helm upgrade. This restores your normal v2 RBAC configuration.

Any schemas registered in v3 after the cutover will not be present in v2 — they are lost on rollback and must be re-added to v2 manually. Moving data from v3 back to v2 is not supported.

Restore Apicurio v3 topics

If you need to discard a failed import and start the import (Step 4) from scratch, the v3 store must be empty again. 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 — empty.

  3. Re-run the import from Step 4.

This restores only the v3 registry store; it does not touch v2. v2 keeps serving reads throughout.

Troubleshooting

Symptom Cause Solution

Registry is not empty on import

Data from a previous import exists

Restore the v3 topics (delete the three v3 topics and restart the v3 pod), then re-run the import

Artifacts imported but show 0 versions

Events topic missing, or no ACLs

Verify the events topic exists and ACLs are set for the Apicurio principal

v3 pod stuck in CrashLoopBackOff

Kafka topics missing or wrong cleanup policy

Check the init container logs

UI shows "Unable to connect"

registryApiUrl not set, or not reachable from the browser

Set ui.config.registryApiUrl to the public HTTPS URL

Auth proxy or backend crashes on startup

Port 8081 conflict between auth proxy and backend

Set authProxy.port and authProxy.config.server.port to 8082, and backend-service to http://localhost:8081

Useful links

End-to-end upgrade script

The script below runs the CLI portion of the upgrade — the export from v2 (Step 3.3), the import into v3 (Step 4) and the verification (Step 5) — in one pass, reusing the same variables as the rest of this page. It does not perform the Helm changes: apply the registration freeze (Step 3.2) before running it, and the ingress cutover (Step 6) after.

The registration freeze must already be in place when you run this. The script reads $V2_COUNT and $V3_COUNT and warns if they differ, but it cannot detect schemas registered in v2 after the export — that is what the freeze prevents.

#!/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"