Flux Mirror Sync Command
The flux mirror sync command mirrors Helm charts and OCI artifacts between
registries based on a declarative
Config. It is
idempotent: re-running against the same config produces the same destination
state, copying only what is missing or drifted. OCI artifacts are copied by the
source digest used for comparison, even if the source tag changes during the
operation.
Synopsis
flux mirror sync [CONFIG|-] [flags]
Configuration source
The config path is resolved in the following order:
- The first positional argument (
-reads the config from stdin). - The
FLUX_MIRROR_CONFIGenvironment variable.
If neither is set, the command errors. Unlike
login and
secret, sync has no executable-relative default path.
flux mirror sync ./flux-mirror.yaml
flux mirror sync - < ./flux-mirror.yaml
FLUX_MIRROR_CONFIG=./flux-mirror.yaml flux mirror sync
Authentication
sync authenticates OCI registry requests — artifacts source/destination and
charts oci:// destinations — per registry host:
- Hosts listed under
hostsuse their configured authentication:provider: ecr|acr|garobtains the registry’s native credentials from the cloud provider’s workload identity. Combined with acredential, it exchanges the credential identity for those credentials instead.credentialresolves a per-host token from a cloud/CI identity, a JWK signature, or an environment variable or file. With nocredential.usernameit is sent as an HTTP Bearer credential; withcredential.usernameit is the password in the standard registry auth challenge.
- Hosts not listed under
hostsuse the ambient Docker config and credential helpers (~/.docker/config.json, or$DOCKER_CONFIGwhen set).
A host whose provider is unset or generic may also set
tls to configure transport-layer TLS for its registry
connections: a directory or inline PEM values with a custom CA and/or client
certificate, or SPIFFE mutual TLS.
With the default registry transport, --insecure skips TLS verification for OCI
requests, including chart destinations and referrer lookups.
Provider credentials obtained from a cloud registry provider or a credential
token source are cached for the duration of the sync run. This avoids minting a
new cloud/CI identity token and exchanging it for registry credentials on every
request. Use --token-cache-max-size and --token-cache-max-duration to tune the
cache, or set the size to 0 to disable it.
HTTP/S Helm repository authentication (for charts sources) is separate from OCI
auth and always comes from the ambient Helm repositories config:
- Helm’s default
repositories.yamlpath, or$HELM_REPOSITORY_CONFIGwhen set. - The
username/password,certFile,keyFile,caFile,insecure_skip_tls_verify, andpass_credentials_allfields are honored.
Add repositories with helm repo add and flux mirror picks up matching HTTP/S
repository credentials automatically.
Flags
| Flag | Default | Description |
|---|---|---|
-o, --output text|yaml|json | text | Output format. text is human-friendly; yaml and json print the structured
sync report to stdout. |
--concurrency N | 4 | Maximum number of copy and tag-planning operations to run in parallel. Must be greater than 0. |
--retries N | 3 | Maximum number of retry attempts per job, within the --timeout budget. Must be greater than or equal to 0. |
--timeout DURATION | 5m | Per-job total budget covering all retry attempts. Must be greater than 0. |
--overwrite | false | Force overwrite: true on every entry, regardless of per-entry config. See
Overwrite and drift behavior. |
--drift-exit-code N | 2 | Exit code to use when drift is detected without failures (0–255). Set to 0 for immutable destinations that should not fail CI on drift. Drift on entries with ignoreDriftForExitCode: true does not trigger it. |
--dry-run | false | Run the plan and comparison pipeline without performing any writes. Reported as would-copy / would-overwrite. |
--verbose | false | Log every operation and the involved digests on stderr. Suppresses the spinner. |
--no-progress | false | Disable the live progress spinner. Per-job lines and the summary still print. |
--insecure | false | Allow plaintext HTTP and skip TLS verification. Test/dev only. |
--token-cache-max-size N | 100 | Maximum number of provider credentials cached. Set to 0 to disable the cache. |
--token-cache-max-duration DURATION | 1h | Maximum duration a cached provider token is reused before it is refreshed. |
Global flags: --timeout sets the default operation timeout, and --no-envsubst disables config environment substitution.
Output
Text mode (default)
✓ ghcr.io/stefanprodan/charts/podinfo:6.10.2 (skipped)
→ localhost:5050/charts/podinfo:6.10.2
✓ ghcr.io/stefanprodan/charts/podinfo:6.11.0 (copied)
→ localhost:5050/charts/podinfo:6.11.0
✗ ghcr.io/stefanprodan/charts/podinfo2 — plan failed: NAME_UNKNOWN
Summary: 1 copied, 1 skipped, 1 failed in 4.2s.
Verbose mode
--verbose replaces the pretty output with a full diagnostic log stream on
stderr — every layer push, blob check, manifest digest, referrer fallback-tag
update, and registry-side warning. Reach for it when diagnosing TLS, auth,
missing-blob, or push-rejection issues.
2026/05/13 09:00:00 sync started entries=3 concurrency=4 retries=3 timeout=5m0s
2026/05/13 09:00:00 mirroring tag src=ghcr.io/foo/bar:1.0 dst=localhost:5050/bar:1.0
2026/05/13 09:00:00 Copying from ghcr.io/foo/bar:1.0 to localhost:5050/bar:1.0
2026/05/13 09:00:00 pushed blob: sha256:fdf53ef8e04176eedbd42713efb2d002f1741c310627b38f444c6f6d92a598f7
2026/05/13 09:00:01 tag done src=ghcr.io/foo/bar:1.0 status=copied elapsed=812ms
…
2026/05/13 09:00:04 sync complete entries=3 copied=1 overwritten=0 skipped=1 drifted=0 failed=1 duration=4.213s
Structured output
-o yaml and -o json print a versioned report to stdout: a top-level envelope
(apiVersion, kind, $schema, report) wrapping run metadata (reporter,
timestamp, durationMs), an aggregate summary of per-status tag counts, and
results[] where each entry carries a status (completed/failed) and a
tags[] array of per-tag rows. Every row has a status and, when known, a
source digest; skipped rows carry a reason; verified rows carry a
verification block; and, with includeReferrers: true, a referrers[] array.
The envelope is documented by the
sync report schema.
# Status of every tag, per entry
flux mirror sync config.yaml -o json | jq '.report.results[].tags[] | {tag, status}'
# Tags that were copied
flux mirror sync config.yaml -o json | jq '.report.results[].tags[] | select(.status == "copied") | .tag'
# Aggregate counts
flux mirror sync config.yaml -o json | jq '.report.summary'
Status
Each tag row (and each referrer row) lands in exactly one of these statuses:
| Status | Meaning |
|---|---|
copied | Destination did not have the tag; mirrored from source. |
overwritten | Destination had a different digest; replaced (only with overwrite: true). |
skipped | Nothing was copied; the row’s reason says why. |
drifted | Destination has a different digest, overwrite: false — left alone, surfaced in the summary. |
would-copy | Dry-run forecast: would have been copied. |
would-overwrite | Dry-run forecast: would have been overwritten. |
failed | The operation errored; the row’s error carries the message. |
A skipped row’s reason is either up-to-date (the destination already has
the same digest) or signature-too-new (a valid signature deferred by
verify.minAge).
A failed signature verification (bad/missing signature, OIDC mismatch) is
recorded as a failed tag row carrying the verify error; verification runs at
plan time, so the first failure stops the entry (tags verified before it still
run) while the entry itself stays completed. A true plan-time failure (e.g. the
source registry rejected a ListTags) surfaces as an entry with status: "failed", an error, and an empty tags array, reported live as a ✗ <entry> — plan failed: <err> line. Both kinds count toward failed.
Referrers
When an entry sets includeReferrers: true, each tag row gains a referrers[]
array — one row per mirrored sub-artifact (cosign signature bundle, SBOM,
attestation), each with its own digest, artifactType, status, and (when
skipped) reason. Referrers are evaluated under --dry-run too.
Exit codes
| Code | Meaning |
|---|---|
0 | Clean run — every tag was copied or skipped as expected, no drift, no failures. |
1 | At least one tag job failed (network error, push rejected, retries exhausted, plan failure). |
2 | No failures, but at least one tag drifted with overwrite: false. The destination is out of date relative to source. |
Failures take precedence over drift. --dry-run does not bump the exit code for
would-copy / would-overwrite, but drift detection still produces 2 by
default. Use --drift-exit-code=0 when the destination registry is known to be
immutable and drift should be logged without failing CI.
Drift on an artifact entry with
ignoreDriftForExitCode: true is still logged and
reported as drifted, but does not contribute to this exit code. A run where all
drifted tags belong to such entries exits 0, and --drift-exit-code is not
applied.
Examples
Mirror HTTP/S Helm charts into OCI Helm charts
Many common Kubernetes ecosystem components are still published only to classic
HTTP/S Helm repositories and do not offer OCI Helm charts. The
charts section pulls those charts over HTTP/S and
re-publishes them as OCI Helm charts so a cluster can consume them from a single
OCI registry.
This config mirrors a few such charts into GitHub Container Registry (GHCR):
# flux-mirror.yaml
apiVersion: mirror.plugin.fluxcd.io/v1beta2
kind: Config
charts:
- name: ingress-nginx
source: https://kubernetes.github.io/ingress-nginx
destination: oci://ghcr.io/my-org/charts
version: ">=4.11.0"
limit: 3
- name: cert-manager
source: https://charts.jetstack.io
destination: oci://ghcr.io/my-org/charts
version: ">=1.15.0"
limit: 3
- name: metrics-server
source: https://kubernetes-sigs.github.io/metrics-server
destination: oci://ghcr.io/my-org/charts
version: ">=3.12.0"
limit: 3
- name: kube-prometheus-stack
source: https://prometheus-community.github.io/helm-charts
destination: oci://ghcr.io/my-org/charts
version: ">=60.0.0"
limit: 3
hosts:
# GHCR is the push destination. It expects a username/password login (the
# username is any non-empty value; GHCR authorizes by the token), so
# `credential.username` is set and GITHUB_TOKEN is the password.
- host: ghcr.io
credential:
username: my-org
value: ${GH_TOKEN}
Each chart lands at ghcr.io/my-org/charts/<name>:<version>. Run it from GitHub
Actions with packages: write so the workflow’s GITHUB_TOKEN can push:
# .github/workflows/mirror-charts.yaml
name: mirror-charts
on:
schedule:
- cron: "0 */6 * * *"
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: fluxcd/flux-mirror/actions/setup@main
- run: flux-mirror sync ./flux-mirror.yaml --no-progress
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Mirror OCI Helm charts into OCI Helm charts
A Helm chart that already lives in an OCI registry is mirrored OCI-to-OCI as a
plain OCI artifact. The
artifacts section is
the only way to mirror an OCI Helm chart between OCI repositories — the
charts section is exclusively for HTTP/S sources.
apiVersion: mirror.plugin.fluxcd.io/v1beta2
kind: Config
artifacts:
- source: ghcr.io/stefanprodan/charts/podinfo
destination: ghcr.io/my-org/charts/podinfo
selector:
semver: ">=6.0.0"
limit: 5
Mirror images from GHCR into a private registry
This mirrors ghcr.io/fluxcd controller images into a private registry from
GitHub Actions. The destination authenticates with a username and password from
GitHub Actions secrets. The GHCR source is authenticated with GITHUB_TOKEN
(rather than pulled anonymously) to avoid anonymous pull rate limits — GHCR
expects a username/password login, so credential.username is set there too.
# flux-mirror.yaml
apiVersion: mirror.plugin.fluxcd.io/v1beta2
kind: Config
artifacts:
- source: ghcr.io/fluxcd/source-controller
destination: registry.internal.example.com/fluxcd/source-controller
selector:
semver: ">=1.0.0"
limit: 5
includeReferrers: true
- source: ghcr.io/fluxcd/kustomize-controller
destination: registry.internal.example.com/fluxcd/kustomize-controller
selector:
semver: ">=1.0.0"
limit: 5
includeReferrers: true
hosts:
# Source: authenticate to GHCR to avoid anonymous rate limits.
- host: ghcr.io
credential:
username: my-org
value: ${GH_TOKEN}
# Destination: a private registry expecting a username/password login. The
# username and password are substituted from the environment while loading the
# config.
- host: registry.internal.example.com
credential:
username: ${REGISTRY_USERNAME}
value: ${REGISTRY_PASSWORD}
# .github/workflows/mirror-images.yaml
name: mirror-images
on:
schedule:
- cron: "0 */6 * * *"
permissions:
contents: read
packages: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: fluxcd/flux-mirror/actions/setup@main
- name: Substitute the destination username into the config
run: envsubst '${REGISTRY_USERNAME}' < flux-mirror.yaml > rendered.yaml
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
- run: flux-mirror sync ./rendered.yaml --no-progress
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
Mirror into ECR, ACR, or GAR from GitHub Actions
To mirror container images, OCI Helm charts, and Flux OCI artifacts into a cloud
registry, authenticate to the cloud provider with its GitHub Actions OIDC login
action, then set
hosts[].provider on the
destination host so flux-mirror reuses that ambient identity. The source here
is GHCR, authenticated with GITHUB_TOKEN.
The config is the same for all three clouds except for the destination host and
provider:
# flux-mirror.yaml — Amazon ECR destination
apiVersion: mirror.plugin.fluxcd.io/v1beta2
kind: Config
artifacts:
- source: ghcr.io/fluxcd/source-controller # container image
destination: 123456789012.dkr.ecr.us-east-1.amazonaws.com/fluxcd/source-controller
selector: { semver: ">=1.0.0", limit: 5 }
includeReferrers: true
- source: ghcr.io/stefanprodan/charts/podinfo # OCI Helm chart
destination: 123456789012.dkr.ecr.us-east-1.amazonaws.com/charts/podinfo
selector: { semver: ">=6.0.0", limit: 5 }
- source: ghcr.io/stefanprodan/manifests/podinfo # Flux OCI artifact
destination: 123456789012.dkr.ecr.us-east-1.amazonaws.com/manifests/podinfo
selector: { regex: { pattern: "^latest$" }, sortBy: alphabetical }
hosts:
- host: ghcr.io
credential:
username: my-org
value: ${GH_TOKEN}
- host: 123456789012.dkr.ecr.us-east-1.amazonaws.com
provider: ecr
For ACR or GAR, change only the destination host and provider:
# Azure ACR
- host: myregistry.azurecr.io
provider: acr
# Google GAR
- host: us-docker.pkg.dev
provider: gar
The workflow differs only in the cloud login step, all using GitHub Actions OIDC (no long-lived cloud keys):
# .github/workflows/mirror-to-ecr.yaml
name: mirror-to-ecr
on:
schedule:
- cron: "0 */6 * * *"
permissions:
contents: read
packages: read
id-token: write # required for GitHub Actions OIDC
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: fluxcd/flux-mirror/actions/setup@main
# --- Amazon ECR ---
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/flux-mirror
aws-region: us-east-1
# --- Azure ACR (instead of the AWS step) ---
# - uses: azure/login@v2
# with:
# client-id: ${{ secrets.AZURE_CLIENT_ID }}
# tenant-id: ${{ secrets.AZURE_TENANT_ID }}
# subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# --- Google GAR (instead of the AWS step) ---
# - uses: google-github-actions/auth@v2
# with:
# workload_identity_provider: projects/123/locations/global/workloadIdentityPools/gh/providers/gh
# service_account: flux-mirror@my-project.iam.gserviceaccount.com
- run: flux-mirror sync ./flux-mirror.yaml --no-progress
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Mirror into ECR, ACR, or GAR from a CronJob with workload identity
Inside a cluster, a CronJob can mirror on a schedule using the pod’s workload
identity. The destination cloud registry is authenticated with
hosts[].provider (which uses the ambient
cloud credential chain — IRSA, AKS Workload Identity, or GKE Workload Identity).
The source registry here accepts the cluster’s ServiceAccount OIDC tokens, so it
is authenticated with a projected ServiceAccount token sent as an HTTP Bearer
credential (no credential.username).
First, the ServiceAccount, annotated for each provider’s workload identity. The projected token’s audience must be the source registry host:
# EKS with IRSA (annotation mandatory)
apiVersion: v1
kind: ServiceAccount
metadata:
name: flux-mirror
namespace: flux-system
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/flux-mirror
# EKS Pod Identity needs no annotation (the association is created via the EKS
# API); the same ServiceAccount works.
---
# AKS Workload Identity (annotation mandatory; the pod also needs the
# azure.workload.identity/use: "true" label)
apiVersion: v1
kind: ServiceAccount
metadata:
name: flux-mirror
namespace: flux-system
annotations:
azure.workload.identity/client-id: 00000000-0000-0000-0000-000000000000
---
# GKE Workload Identity Federation. With direct KSA-to-IAM bindings no
# annotation is needed; when impersonating a GCP service account, annotate it:
apiVersion: v1
kind: ServiceAccount
metadata:
name: flux-mirror
namespace: flux-system
annotations:
iam.gke.io/gcp-service-account: flux-mirror@my-project.iam.gserviceaccount.com
The config authenticates the destination with provider and the source with the
projected token. Because
file-path fields are confined to the config’s own
directory, the config and the token
are mounted together (below) so a relative fromPath resolves:
# mirror.yaml
apiVersion: mirror.plugin.fluxcd.io/v1beta2
kind: Config
artifacts:
- source: source-registry.example.com/library/app
destination: 123456789012.dkr.ecr.us-east-1.amazonaws.com/library/app
selector: { semver: ">=1.0.0", limit: 5 }
hosts:
# Source: a registry that validates the cluster's ServiceAccount OIDC token.
# No credential.username → the token is sent as an HTTP Bearer credential. Its
# audience (the source host) is set by the projected-token volume, not here.
- host: source-registry.example.com
credential:
fromPath: registry-token
# Destination: ECR via the pod's IRSA identity (use acr/gar to switch clouds).
- host: 123456789012.dkr.ecr.us-east-1.amazonaws.com
provider: ecr
The CronJob runs as the annotated ServiceAccount and projects a token whose
audience is the source registry, combined with the config in one volume so the
relative fromPath stays within the config directory:
apiVersion: batch/v1
kind: CronJob
metadata:
name: flux-mirror
namespace: flux-system
spec:
schedule: "0 */6 * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
metadata:
labels:
azure.workload.identity/use: "true" # AKS Workload Identity only
spec:
serviceAccountName: flux-mirror
restartPolicy: OnFailure
containers:
- name: flux-mirror
image: ghcr.io/fluxcd/flux-mirror:latest
args: ["sync", "/config/flux/mirror.yaml", "--no-progress"]
volumeMounts:
- name: flux
mountPath: /config/flux
readOnly: true
volumes:
# One projected volume holds both the config and the registry token,
# so /config/flux/mirror.yaml can reference ./registry-token.
- name: flux
projected:
sources:
- configMap:
name: flux-mirror-config # holds mirror.yaml
- serviceAccountToken:
path: registry-token
audience: source-registry.example.com
expirationSeconds: 3600
Mirror with the AWS credential provider into a SPIFFE mTLS registry
This CronJob pulls container images from a registry that authenticates the
caller’s AWS identity, and pushes them to a registry that requires SPIFFE
X.509-SVID mTLS. The source host uses the
aws credential provider, which signs an
sts:GetCallerIdentity request the source registry replays to AWS STS, so the
pod needs AWS credentials via IRSA. The destination host uses
tls.spiffe and no HTTP credential — the registry
authorizes the client by its X.509-SVID and the server is verified by its SPIFFE
identity. The SPIFFE SVIDs and trust bundle come from the
Workload API, which the SPIFFE CSI driver exposes to the pod; go-spiffe locates
the socket through SPIFFE_ENDPOINT_SOCKET.
The ServiceAccount is annotated for IRSA so the aws provider can sign as the
mapped AWS role:
apiVersion: v1
kind: ServiceAccount
metadata:
name: flux-mirror
namespace: flux-system
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/flux-mirror
# mirror.yaml
apiVersion: mirror.plugin.fluxcd.io/v1beta2
kind: Config
artifacts:
- source: source-registry.example.com/library/app
destination: registry.example.org/library/app
selector: { semver: ">=1.0.0", limit: 5 }
hosts:
# Source: a registry that validates the caller's AWS identity.
# No credential.username → the signed sts:GetCallerIdentity envelope is sent
# as an HTTP Bearer credential.
- host: source-registry.example.com
credential:
provider: aws
# Destination: SPIFFE mTLS in both directions, no HTTP credential. Our client
# X.509-SVID authenticates us; the server's SVID is verified against our own
# trust domain.
- host: registry.example.org
tls:
spiffe:
serverIDs:
- spiffe://self
apiVersion: batch/v1
kind: CronJob
metadata:
name: flux-mirror
namespace: flux-system
spec:
schedule: "0 */6 * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
serviceAccountName: flux-mirror
restartPolicy: OnFailure
containers:
- name: flux-mirror
image: ghcr.io/fluxcd/flux-mirror:latest
args: ["sync", "/config/flux/mirror.yaml", "--no-progress"]
env:
# go-spiffe reads the Workload API socket from this variable.
- name: SPIFFE_ENDPOINT_SOCKET
value: unix:///spiffe-workload-api/spire-agent.sock
volumeMounts:
- name: config
mountPath: /config/flux
readOnly: true
- name: spiffe-workload-api
mountPath: /spiffe-workload-api
readOnly: true
volumes:
- name: config
configMap:
name: flux-mirror-config # holds mirror.yaml
# The SPIFFE CSI driver exposes the SPIRE Agent Workload API socket.
- name: spiffe-workload-api
csi:
driver: csi.spiffe.io
readOnly: true
Preview, resync, and CI invocations
# Preview the plan without writing to the destination.
flux mirror sync ./flux-mirror.yaml --dry-run -o yaml
# Force-resync every drifted tag.
flux mirror sync ./flux-mirror.yaml --overwrite
# CI-friendly: no spinner. For immutable destinations, keep CI green on drift.
flux mirror sync ./flux-mirror.yaml --no-progress --drift-exit-code=0