# Migrating from Argo CD to Flux CD without downtime

> How to move applications from Argo CD to Flux CD one at a time without restarting anything: how the concepts map, how to hand running resources over safely, how to adopt Helm releases, and the mistakes that cause outages.

Published: 2026-11-05
Author: k8wiz Team
URL: https://k8wiz.com/articles/migrating-from-argo-cd-to-flux-cd-without-downtime

Moving from Argo CD to Flux doesn't require redeploying anything. Both tools apply ordinary Kubernetes objects, so the workloads keep running while you change which controller owns them. The risk is in the handover. Delete an Argo CD Application the wrong way and Argo CD deletes everything it deployed.

This guide moves one application at a time, so each step is small and easy to undo. It assumes Argo CD v3, which tracks resources with an annotation by default, and Flux 2 with its stable v1 APIs. Most steps work the same on Argo CD v2.

## Should you move at all?

Flux and Argo CD solve the same problem in different ways. Flux is a set of controllers with no web UI of its own. You work through Git, the `flux` CLI and the Kubernetes API, and each cluster usually runs its own Flux. Argo CD is built around its UI and API server, and one instance often manages many clusters.

Moving makes sense when platform tooling should drive deployments through Kubernetes objects, when you want real Helm releases that `helm list` shows, or when you'd rather each cluster pull its own config than give one central server credentials for every cluster.

Staying makes sense when developers depend on the Argo CD UI, when ApplicationSets with cluster generators manage a large fleet from one place, or when many applications rely on sync waves and hooks. Flux has answers for each of those, but they take more work than a mapping table suggests.

## How the concepts map

| Argo CD | Flux CD |
| --- | --- |
| Application with a Git path | GitRepository plus a Kustomization |
| Application with a Helm chart | HelmRepository or OCIRepository plus a HelmRelease |
| App of apps | A Kustomization that applies other Kustomizations |
| ApplicationSet | A directory per cluster in Git, or the Flux Operator's ResourceSet API |
| AppProject | A namespace per tenant, `serviceAccountName` impersonation and RBAC |
| `syncPolicy.automated` | Always on; `spec.interval` sets how often Flux reconciles |
| `automated.prune` | `spec.prune: true` |
| `automated.selfHeal` | On by default for Kustomizations; `driftDetection.mode: enabled` for HelmReleases |
| Sync waves | `dependsOn` between Kustomizations or HelmReleases |
| Sync hooks | Helm hooks in a HelmRelease, or a separate Kustomization with `dependsOn` |
| `ignoreDifferences` | `kustomize.toolkit.fluxcd.io/ssa` annotations, or `driftDetection.ignore` for Helm |
| Manual sync | `flux reconcile kustomization <name> --with-source` |
| `argocd app diff` | `flux diff kustomization` |
| Argo CD Notifications | notification-controller Providers and Alerts |
| Argo CD Image Updater | ImageRepository, ImagePolicy and ImageUpdateAutomation |
| Config Management Plugins | Render in CI and push the result with `flux push artifact` |
| Repository credentials | A Secret referenced by `spec.secretRef` on the source |

Two rows hide real changes in behavior.

**Helm releases become real.** Argo CD only uses Helm to render charts with `helm template`. The cluster has the chart's objects, but `helm list` shows nothing and no release Secrets exist. Flux's helm-controller installs real Helm releases, which is why Helm applications need an extra step during the handover.

**Drift correction is the default.** A Flux Kustomization reapplies its manifests on every interval, so manual changes are undone, as if every Argo CD Application had self-heal turned on. If your team hotfixes with `kubectl edit`, it now needs `flux suspend kustomization <name>` first.

## Before you start

Make a list of what Argo CD manages and how:

```sh
argocd app list -o wide
kubectl get applications -A -o yaml > argocd-applications-backup.yaml
```

For each Application, note whether it deploys plain manifests, Kustomize or a Helm chart, the Helm release name and values, sync options such as `CreateNamespace=true`, and any sync waves, hooks or `ignoreDifferences`. Those are the parts that need more than a direct translation.

Then install Flux next to Argo CD. Both can run in the same cluster, as long as they never manage the same object at the same time.

```sh
flux check --pre
flux bootstrap github \
  --owner=my-org \
  --repository=fleet \
  --branch=main \
  --path=clusters/production
```

Bootstrap commits Flux's own manifests under `clusters/production/flux-system` and starts syncing that path. Nothing Argo CD manages changes yet.

## Lay out the repository

This layout works for most teams. It separates each cluster's entry point from the definitions clusters share:

```
fleet/
├── clusters/
│   └── production/
│       ├── flux-system/          # written by flux bootstrap
│       ├── infrastructure.yaml   # Kustomization for infrastructure/
│       └── apps.yaml             # Kustomization for apps/, dependsOn infrastructure
├── infrastructure/               # CRDs, controllers, ingress, cert-manager
└── apps/
    └── my-app/
```

The apps Kustomization depends on the infrastructure one, so CRDs and controllers are ready before anything that needs them. That alone replaces most of what sync waves did across applications.

## Hand over one application

Start with a low-risk application that uses plain manifests or Kustomize. These steps move it from Argo CD to Flux without touching the running pods.

### 1. Write the Flux objects

If the manifests already live in Git, point Flux at the same path Argo CD uses:

```yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
  name: my-app
  namespace: flux-system
spec:
  interval: 5m
  url: https://github.com/my-org/my-app-config
  ref:
    branch: main
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: my-app
  namespace: flux-system
spec:
  interval: 10m
  sourceRef:
    kind: GitRepository
    name: my-app
  path: ./deploy/production
  prune: true
  wait: true
  timeout: 5m
  targetNamespace: my-app
```

Set `ref.branch` even when it seems obvious. A GitRepository with no `ref` checks out `master` and fails on repositories that only have `main`.

`targetNamespace` plays the part of the Application's destination namespace. Argo CD only fills in namespaces that are missing, but `targetNamespace` also overrides the ones written in the manifests, so leave it out if the manifests set their own.

If the path has no `kustomization.yaml`, Flux generates one that includes every manifest under it, much like Argo CD's directory mode. Keep files that aren't Kubernetes manifests out with a `.sourceignore` file.

Don't commit the Flux objects yet. Keep them on a branch or in a local file, with the Kustomization in a file of its own for the next step.

### 2. Preview the change

Check what Flux would change before it owns anything:

```sh
flux build kustomization my-app \
  --path ./deploy/production \
  --kustomization-file ./flux/my-app-kustomization.yaml \
  --dry-run

flux diff kustomization my-app \
  --path ./deploy/production \
  --kustomization-file ./flux/my-app-kustomization.yaml \
  --ignore-not-found
```

The diff should only show metadata, such as the `kustomize.toolkit.fluxcd.io/name` and `kustomize.toolkit.fluxcd.io/namespace` labels Flux adds to every object it applies. A renamed object, a changed selector or a missing object means the Flux setup doesn't match what Argo CD deployed. Fix that before you continue, because with `prune: true` Flux would delete the old object and create a new one.

### 3. Stop Argo CD without deleting anything

This is the step that causes outages when it goes wrong. Deleting an Application that has the `resources-finalizer.argocd.argoproj.io` finalizer is a cascade delete. Argo CD removes every resource the Application deployed.

Turn off automated sync, then delete the Application without cascading:

```sh
argocd app set my-app --sync-policy none
argocd app delete my-app --cascade=false
```

Without the CLI, remove the finalizer first and then delete the object:

```sh
kubectl -n argocd patch application my-app --type json \
  -p '[{"op": "remove", "path": "/metadata/finalizers"}]'
kubectl -n argocd delete application my-app
```

Then check that the workloads are still running with `kubectl -n my-app get all`.

App of apps setups need more care, because the parent Application manages the child Application objects:

1. Turn off automated sync on the parent, so it can't restore the child's sync policy or finalizer while you work.
2. Remove the finalizer from the child and delete it as shown above.
3. Remove the child from the parent's Git directory, then turn the parent's sync back on.

Get the order wrong and one of two things happens. If the parent prunes and the child still has its finalizer, Argo CD deletes the workloads. If the child is still in the parent's directory, the parent recreates it on its next sync and takes the resources back.

### 4. Let Flux take over

Commit the Flux objects to the fleet repository, then trigger and watch the first reconciliation:

```sh
flux reconcile kustomization flux-system --with-source
flux get kustomizations my-app
flux tree kustomization my-app
```

`Ready` with the revision you expect means Flux now owns the application. `flux tree` lists every object it manages, so compare it with your inventory. kustomize-controller applies with server-side apply and labels the top-level objects, not their pod templates, so pods only restart if the manifests really changed.

### 5. Clean up what Argo CD left behind

Argo CD v3 leaves its `argocd.argoproj.io/tracking-id` annotation on every object it managed. It does no harm, but removing it avoids confusion if Argo CD ever runs in the cluster again:

```sh
kubectl -n my-app annotate deployment,service,configmap,ingress --all \
  argocd.argoproj.io/tracking-id-
```

With label tracking, the default in Argo CD v2, Argo CD set the `app.kubernetes.io/instance` label instead. Don't remove that one blindly, because many charts and manifests use it in selectors.

Also remove Argo CD annotations such as sync waves, hooks and sync options from the manifests in Git. Flux ignores them, so they only mislead the next person who reads the files.

## Helm applications need one more step

Argo CD rendered the chart with `helm template`, so the cluster has the chart's objects but no Helm release. When helm-controller installs the HelmRelease, Helm finds objects it doesn't own and stops with an `invalid ownership metadata` error.

Helm adopts an existing object if it has the `app.kubernetes.io/managed-by: Helm` label and the `meta.helm.sh/release-name` and `meta.helm.sh/release-namespace` annotations. Add them to every object the chart created before you hand the application over. `argocd app resources my-app` lists those objects while the Application still exists.

```sh
release=my-app   # Argo CD's release name: the Application name, unless helm.releaseName is set
ns=my-app
for obj in deployment/my-app service/my-app configmap/my-app serviceaccount/my-app; do
  kubectl -n "$ns" label "$obj" app.kubernetes.io/managed-by=Helm --overwrite
  kubectl -n "$ns" annotate "$obj" \
    meta.helm.sh/release-name="$release" \
    meta.helm.sh/release-namespace="$ns" --overwrite
done
```

Cluster-scoped objects such as ClusterRoles need the same label and annotations; leave out `-n` for those.

Then write the HelmRelease with the same release name, namespace and values Argo CD used:

```yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: my-charts
  namespace: flux-system
spec:
  interval: 1h
  url: https://charts.example.com
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: my-app
  namespace: my-app
spec:
  interval: 10m
  releaseName: my-app
  chart:
    spec:
      chart: my-app
      version: "1.4.2"
      sourceRef:
        kind: HelmRepository
        name: my-charts
        namespace: flux-system
  values:
    replicaCount: 3
  driftDetection:
    mode: enabled
    ignore:
      - paths: ["/spec/replicas"]
        target:
          kind: Deployment
```

The release name matters most. Charts use it in object names, so a different name makes Helm create new objects next to the old ones. A HelmRelease's default release name is `[<targetNamespace>-]<name>`, which rarely matches what Argo CD used, so set `releaseName` explicitly. Putting the HelmRelease in the application's namespace keeps the release and its storage Secrets there, matching the `release-namespace` annotation.

Pin the chart version Argo CD deployed and copy the values exactly, including any value files and parameters. Upgrade the chart in a separate change after the handover, so a failed upgrade can't be confused with a failed migration.

Drift detection is off by default for HelmReleases. `mode: enabled` gives you what self-heal did in Argo CD, and the ignore rule leaves replicas to a HorizontalPodAutoscaler. For charts in an OCI registry, point `spec.chartRef` at an OCIRepository instead of using `chart.spec`.

## Replacing sync waves and hooks

Flux doesn't read `argocd.argoproj.io/sync-wave` or `argocd.argoproj.io/hook`. Annotated resources are applied like any other object.

Sync waves usually order a handful of things: CRDs before the custom resources that use them, a database before the app, a migration before the rollout. Put each group in its own Kustomization and chain them:

```yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: my-app
  namespace: flux-system
spec:
  dependsOn:
    - name: my-app-database
  interval: 10m
  sourceRef:
    kind: GitRepository
    name: my-app
  path: ./deploy/production/app
  prune: true
  wait: true
```

`dependsOn` waits until the dependency is Ready, and when the dependency sets `wait: true`, Ready means every object in it is healthy.

Hooks take more thought. A PreSync migration Job turns into a plain Job, and since a Job's pod template can't be changed, the next change to it fails to apply. Two approaches work:

- If the application is a Helm chart, make the Job a Helm `pre-upgrade` hook. helm-controller runs Helm hooks the same way Helm does.
- Otherwise, put the Job in its own Kustomization that the application depends on, and annotate the Job with `kustomize.toolkit.fluxcd.io/force: enabled` so Flux recreates it when its spec changes. Change the Job's spec with each release, for example its image tag, so it runs again.

## Replacing ignoreDifferences, notifications and image updates

**ignoreDifferences.** The usual reason for it is a field another controller owns, such as replicas under an HPA or a `caBundle` that cert-manager injects. The cleanest fix is to remove the field from the manifest so Flux never sets it. When that isn't possible, annotate the object:

- `kustomize.toolkit.fluxcd.io/ssa: Merge` keeps fields that other tools added, as long as they don't overlap with fields in the manifest.
- `kustomize.toolkit.fluxcd.io/ssa: IfNotPresent` creates the object once and never updates it, which suits Secrets and webhook configurations that cert-manager fills in.

For HelmReleases, use `driftDetection.ignore` as in the example above.

**Notifications.** Recreate each Argo CD notification as a Provider, which says where to send, and an Alert, which says which events to send:

```yaml
apiVersion: notification.toolkit.fluxcd.io/v1beta3
kind: Provider
metadata:
  name: slack
  namespace: flux-system
spec:
  type: slack
  channel: deployments
  address: https://slack.com/api/chat.postMessage
  secretRef:
    name: slack-token   # a Secret with a "token" key holding the Slack bot token
---
apiVersion: notification.toolkit.fluxcd.io/v1beta3
kind: Alert
metadata:
  name: production
  namespace: flux-system
spec:
  providerRef:
    name: slack
  eventSeverity: error
  eventSources:
    - kind: Kustomization
      name: "*"
    - kind: HelmRelease
      name: "*"
      namespace: my-app
```

A Provider of type `github`, `gitlab`, `gitea` or `bitbucket` can also report commit statuses, so the Git UI shows whether each commit reached the cluster.

**Image updates.** Argo CD Image Updater can write new tags back to Git or to the Application. Flux's image automation always commits to Git. An ImageRepository scans the registry, an ImagePolicy picks the tag, and an ImageUpdateAutomation edits the fields marked with a `# {"$imagepolicy": "flux-system:my-app"}` comment. The deploy key from bootstrap is read-only, so give image automation write access with `--read-write-key` or a separate credential.

## Multi-cluster and tenants

Argo CD usually runs once and deploys to many clusters. The common Flux setup is the reverse. You bootstrap Flux in every cluster, each syncing its own `clusters/<name>` directory from the same repository, so no cluster holds credentials for another. If you need central control, a Kustomization can target a remote cluster through `spec.kubeConfig`.

AppProjects become namespaces. Give each team a namespace with its own Flux objects, and set `serviceAccountName` so Flux applies their manifests with their RBAC. Start the controllers with `--no-cross-namespace-refs=true`, so a tenant can't point at another tenant's sources, and with `--default-service-account`, so Kustomizations that leave out `serviceAccountName` still get a restricted account.

Flux itself has no equivalent of ApplicationSets. A directory per cluster or environment, with `postBuild.substitute` for the values that differ, covers most generator setups. The Flux Operator, a separate project, adds a ResourceSet API that generates Flux objects from a list of inputs.

## Removing Argo CD

Uninstall Argo CD only after `kubectl get applications -A` returns nothing. If you delete the Argo CD namespace or its CRDs while Applications still have finalizers, Argo CD either deletes their resources or leaves the Applications stuck in deletion.

If Argo CD manages itself, delete that Application last with `--cascade=false`, then uninstall Argo CD the same way you installed it.

## Checklist for each application

- [ ] Flux objects use the same names, namespaces, release name and values as before
- [ ] `flux diff kustomization` shows only metadata changes
- [ ] Automated sync is off and the Application was deleted without cascading
- [ ] The workloads are still running
- [ ] For Helm, every object has the ownership label and annotations
- [ ] The Flux objects are committed, Ready, and `flux tree` matches the inventory
- [ ] Argo CD's tracking annotations are removed
