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.
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.credentialresolves a per-host token from a cloud/CI identity, a JWK signature, or an environment variable or file. With nousernameit is sent as an HTTP Bearer credential; with ausernameit 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 non-provider host may also set
tls to
configure transport-layer TLS for its registry connections: a custom CA, a client
certificate (mTLS), or SPIFFE X.509-SVID mTLS.
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 operations to run in parallel per job. 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. |
--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. |
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.
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/v1beta1
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 `username`
# is set and GITHUB_TOKEN is the password.
- host: ghcr.io
username: my-org
credential:
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/v1beta1
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 username is set there too.
# flux-mirror.yaml
apiVersion: mirror.plugin.fluxcd.io/v1beta1
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
username: my-org
credential:
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
username: ${REGISTRY_USERNAME}
credential:
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/v1beta1
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
username: my-org
credential:
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 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/v1beta1
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 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 with a SPIFFE client certificate and
SPIFFE server verification, and no HTTP credential — the registry authorizes the
client by its X.509-SVID. 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/v1beta1
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 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:
clientAuth:
provider: x509-svid
serverAuth:
spiffe:
trustDomain: 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