ArgoCD's selfHeal: true is a beautiful thing — until it applies a bad manifest to production at 2am. The GitOps model moves the decision to Git, but Git has no opinion about whether right now is the right time. An ArgoCD approval gate inserts a human between "the repo changed" and "the cluster changed".
Argo CD production sync approval: three layers, one decision
| Layer | What it stops | Who decides |
|---|---|---|
| PR review + CODEOWNERS | Bad manifests reaching production branch | Team review |
| Manual sync (no auto-sync) | Any sync without an explicit trigger | Release owner |
| Approval gate | Sync before sign-off, with context | A named human, on record |
Step 1: disable auto-sync for production
The first gate is structural — production apps never auto-sync:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: payments-prod
namespace: argocd
spec:
project: production
source:
repoURL: https://github.com/acme/config.git
targetRevision: main
path: overlays/prod
destination:
server: https://kubernetes.default.svc
namespace: payments
syncPolicy:
# No 'automated' block — production syncs are always manual
syncOptions:
- CreateNamespace=false
- Validate=trueArgoCD reports OutOfSync; nothing applies until someone syncs. That's the hook for your gate.
Step 2: ask before the sync
Watch for OutOfSync on production apps and create a decision — with the diff summary attached:
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-notifications-cm
namespace: argocd
data:
trigger.on-prod-pending: |
- when: app.status.sync.status == 'OutOfSync' and app.metadata.labels.environment == 'production'
send: [actionbox-approval]
template.actionbox-approval: |
message: |
{{.app.metadata.name}} is OutOfSync for production.
Desired revision: {{.app.status.sync.comparedTo.source.targetRevision}}The notification template can call your approval endpoint with the app name, namespace, and revision — the approver sees exactly what's waiting.
Step 3: the CI gate with context
For more context than notifications can carry, gate the sync in a pipeline job — the same pattern as any deploy gate:
name: promote-to-prod
on:
workflow_dispatch:
inputs:
app:
description: ArgoCD application to sync
required: true
jobs:
approval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check ArgoCD status
run: |
argocd login $ARGOCD_SERVER --grpc-web --username $ARGOCD_USER --password $ARGOCD_PASS
argocd app get ${{ inputs.app }} -o json | jq -r '.status.sync.status'
- name: Install Actionbox CLI
run: curl -fsSL https://actionbox.cloud/install.sh | sh
- name: Request sync approval
id: approval
env:
ACTIONBOX_TOKEN: ${{ secrets.ACTIONBOX_TOKEN }}
run: |
result=$(actionbox ask "Sync ${{ inputs.app }} to production?" \
--option approve="Approve sync" \
--option reject="Reject sync" \
--context-json "[{\"type\":\"key_value\",\"items\":{\"app\":\"${{ inputs.app }}\",\"environment\":\"production\",\"gate\":\"argocd manual sync + approval\"}}]" \
--wait --timeout 30m --json)
echo "decision=$(jq -r '.decision // empty' <<< "$result")" >> "$GITHUB_OUTPUT"
- name: Sync on approval
if: steps.approval.outputs.decision == 'approve'
run: |
argocd app sync ${{ inputs.app }} --prune
argocd app wait ${{ inputs.app }} --timeout 300
- name: Fail on reject or timeout
if: steps.approval.outputs.decision != 'approve'
run: |
echo "Sync not approved: ${{ steps.approval.outputs.decision }}"
exit 1Full example: PreSync hook that blocks the sync
A PreSync hook can enforce the approval check inside the cluster — the sync literally cannot proceed without a resolved decision:
apiVersion: batch/v1
kind: Job
metadata:
name: approval-check
namespace: argocd
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
template:
spec:
containers:
- name: check
image: alpine/curl:latest
command:
- /bin/sh
- -c
- |
STATUS=$(curl -fsS -H "Authorization: Bearer $APPROVAL_TOKEN" \
"$APPROVAL_API/actions/$APPROVAL_ID/status")
echo "$STATUS" | grep -q '"decision":"approve"' \
|| { echo "Not approved"; exit 1; }
env:
- name: APPROVAL_TOKEN
valueFrom:
secretKeyRef: { name: approval-secrets, key: token }
- name: APPROVAL_ID
value: "prod-sync-2026-08-16"
- name: APPROVAL_API
value: "https://app.actionbox.cloud"
restartPolicy: Never
backoffLimit: 0If the approval doesn't exist or isn't approve, the hook fails, the sync stops, and the app stays OutOfSync — fail closed, with the record intact.
Why this beats "manual sync only"
- Manual sync controls who can click — approval controls what they approve — context, revision, and risk before the button
- Decisions on record — who approved which sync to which revision, when, and why
- Escalation built in — a pending sync has an SLA and a queue, not a Slack thread
- Same gate for every environment — staging can require one approver, production two, with identical plumbing
Try it
- Install the CLI on your release host or runner
- Create a Source →
ACTIONBOX_TOKEN - Add the approval step before
argocd app syncin your promote job
Create a free Source · GitHub Actions + GitOps reference · Deploy approval gates in GitHub Actions