Skip to content

Argo CD production sync approval: a Kubernetes GitOps gate

Add an Argo CD approval gate before production sync. Review a specific Git revision, handle rejection and timeouts, and verify the deployment.

Laptop and deployment tools on a developer workspace

An Argo CD approval gate separates permission to start a sync from the decision to release a particular change. Disable automatic sync for the production application, ask a reviewer about an immutable Git revision, and sync that same revision only after approval.

This guide uses a CI gate around a single-source Git application. ActionBox records the human decision; your runner remains responsible for enforcing it and executing the sync. A notification alone cannot prevent a deployment.

Choose the right approval boundary

ControlPurposeLimitation
Pull-request reviewReview changes before mergeDoes not necessarily approve release timing
Manual Argo CD syncRequire an explicit sync operationAn authorized user can still sync without your CI gate
CI approval gateReview a specific revision before the runner syncs itRequires access controls that prevent bypass
PreSync hookRun a check within the sync lifecycleA different integration that needs its own authenticated check and lifecycle handling

Use Argo CD RBAC to restrict production sync access to the authorized release path. Protect application configuration and workflow changes too: a reviewer cannot enforce a gate that another actor can remove. Define any emergency bypass separately and record its use.

Prepare the application and runner

Before using the example:

  • Configure a single-source Git application with a fixed repository and path. Multi-source applications need approval of every source revision and are outside this example.
  • Omit spec.syncPolicy.automated from the production Application. If an ApplicationSet manages it, change the owning template rather than only the generated Application.
  • Use an isolated release runner with Bash, Python 3, jq, and an authenticated Argo CD CLI compatible with your server. Configure TLS trust; do not disable certificate verification.
  • Install the public ActionBox CLI package: python -m pip install actionbox==0.2.1.
  • Create an ActionBox Source and inject its key as ACTIONBOX_TOKEN through your runner's secret store. Never put it in a checked-in workflow or approval description.
  • Set APP to the authorized application name and REVISION to a reviewed, full 40-character Git commit SHA. Confirm the commit belongs to that application's repository and release branch.
  • Serialize releases for this application. Keep its repository, path, parameter overrides and manifest dependencies stable during review. A Git SHA does not freeze externally mutable Helm dependencies or other configuration.

For example, the relevant part of an Application can omit automated sync:

yaml
spec:
  syncPolicy:
    syncOptions:
      - Validate=true

This is a configuration fragment, not a complete Application manifest. Confirm that another controller will not restore automatic sync.

Request approval and sync the reviewed revision

Run this script from the prepared release runner. Reviewers need access to the repository and release evidence. The request identifies the application and exact commit; provide your deployment plan, change summary and rollback procedure through the review process before approving.

bash
#!/usr/bin/env bash
set -euo pipefail

: "${APP:?Set the authorized Argo CD application}"
: "${REVISION:?Set the full Git commit SHA}"
: "${ACTIONBOX_TOKEN:?Inject the ActionBox Source key}"
[[ "$APP" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || exit 1
[[ "$REVISION" =~ ^[0-9a-f]{40}$ ]] || exit 1

# Stop if this application's configuration differs during human review.
spec_before=$(argocd app get "$APP" -o json | jq -cS '.spec')
printf '%s' "$spec_before" | jq -e '
  .source.repoURL != null and .sources == null and
  .syncPolicy.automated == null
' >/dev/null

expires=$(python3 -c 'from datetime import datetime,timedelta,timezone; print((datetime.now(timezone.utc)+timedelta(minutes=30)).isoformat())')
result=$(actionbox ask "Approve production sync of $APP?"   --description "Application: $APP. Git commit: $REVISION. Review the release plan before approving. No pruning is requested."   --option approve="Approve this revision"   --option reject="Reject release"   --expires "$expires"   --wait --timeout 30m --json)

printf '%s' "$result" | jq -e   '.status == "resolved" and .decision == "approve"' >/dev/null

spec_after=$(argocd app get "$APP" -o json | jq -cS '.spec')
if [[ "$spec_before" != "$spec_after" ]]; then
  echo "Application configuration changed; obtain a new review." >&2
  exit 1
fi

argocd app sync "$APP" --revision "$REVISION"
argocd app wait "$APP" --sync --health --timeout 300
argocd app get "$APP" -o json | jq -e --arg revision "$REVISION"   '.status.sync.revision == $revision and .status.sync.status == "Synced" and .status.health.status == "Healthy"' >/dev/null

The script requires both a resolved Action and the explicit approve option. A rejected, expired or malformed result does not authorize the sync. A CLI error stops the shell before deployment. The server expiry and local wait timeout are separate boundaries; setting both limits how long the request and runner may wait.

The application-spec comparison detects configuration changes during review. It is not an atomic lock: access control and release serialization must prevent a competing change between the check and sync. The explicit --revision avoids accidentally deploying a newer branch head. This example does not request pruning; resource deletion needs its own reviewed scope.

Handle failure without reusing an old decision

OutcomeRequired response
Rejection or expiryStop; investigate or revise the proposal
Runner interrupted or network failureInspect the Action and Argo CD state before retrying; never infer approval from silence
Application configuration changedStop and request review of the new configuration
Sync or health check failsTreat the release as failed; approval does not prove successful execution
Commit or deployment scope changesCreate a new request describing the new scope

A fresh invocation creates a fresh approval request. Avoid blindly restarting the entire script after a disconnect: the previous request may remain pending until expiry, or the sync may already have started. Inspect both systems and recover deliberately. This example does not implement automatic rollback or exactly-once deployment.

Verify your integration before production

Use a disposable application to exercise approval, rejection, expiry, runner interruption and sync failure. Change the Application spec while a review is open and confirm the script stops. Confirm that a changed branch head cannot replace the SHA passed to --revision. Finally, verify that a user without release authority cannot bypass CI with a direct sync.

The shell example can be checked with stubbed CLI responses, but that does not validate your cluster permissions, manifest rendering or network configuration. Those checks belong in your integration environment.

Continue building the release workflow

For a GitHub-hosted pipeline, see GitHub Actions deployment approvals. For approvals that arrive through callbacks, see human approval webhooks. Keep release decisions connected to their execution outcomes with an audit trail.

Create a free Source and follow the CLI integration guide to connect your runner to the hosted service.

Sources & Research

About the author

Suson Sapkota

Suson founded ActionBox and works in software and data engineering. He writes about approval workflows, background jobs, and how to verify what happened after a human decision.

Turn the next risky operation into a reviewable decision.

Create a free Source, run the example from this guide, and keep the decision and execution outcome connected.