GitOps: Deploy Docker Containers with GitHub Actions and ArgoCD
GitOps is the modern way to deploy containerized applications. Instead of SSH-ing into servers or manually triggering deployments, you declare your desired state in Git and let automated tools handle the rest. This guide shows you how to build a complete GitOps pipeline using GitHub Actions for continuous integration and ArgoCD for continuous deployment to Kubernetes.
What is GitOps?
GitOps uses Git as the single source of truth for your infrastructure and application deployments. The core principles are:
- Declarative Configuration: Define your desired state in YAML files
- Version Controlled: All changes go through Git with full history
- Automated Sync: Tools continuously reconcile actual state with desired state
- Pull-Based Deployment: The cluster pulls changes rather than CI pushing them
How GitOps Works (Step by Step):
1. You push code to GitHub
|
v
2. GitHub Actions builds a Docker image
|
v
3. Image is pushed to a container registry (like GHCR)
|
v
4. GitHub Actions updates the GitOps repo with the new image tag
|
v
5. ArgoCD (running in your cluster) watches the GitOps repo
|
v
6. ArgoCD sees the change and deploys the new version automatically
The key insight: Your cluster PULLS updates from Git.
You never SSH into servers or run kubectl manually.
Why GitOps Over Traditional SSH Deployments?
Traditional CI/CD often uses SSH to push changes to servers:
| Traditional SSH | GitOps |
|---|---|
| CI pushes to servers | Cluster pulls from Git |
| Secrets in CI pipelines | Secrets stay in cluster |
| Imperative commands | Declarative manifests |
| Hard to audit | Full Git history |
| Drift goes undetected | Continuous reconciliation |
GitOps provides better security (no SSH keys in CI), better auditability (Git history), and self-healing capabilities (automatic drift correction).
Prerequisites
Before you begin, ensure you have:
- A GitHub repository with your application code
- Docker installed locally for testing
- A Kubernetes cluster (minikube, kind, or cloud-based)
- kubectl configured to access your cluster
- Basic familiarity with Kubernetes manifests
Project Structure
The recommended GitOps setup uses two repositories:
my-app/ # Application Repository
├── src/
├── Dockerfile
├── package.json
└── .github/workflows/
└── ci.yaml # Build and push image
my-app-gitops/ # GitOps Repository
├── base/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── kustomization.yaml
└── overlays/
├── staging/
│ └── kustomization.yaml
└── production/
└── kustomization.yaml
This separation keeps application code and deployment configuration independent, allowing different teams to manage each.
Step 1: Configure GitHub Actions for CI
Create a workflow that builds your Docker image and pushes it to GitHub Container Registry (GHCR).
Create .github/workflows/ci.yaml:
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
update-gitops:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout GitOps repo
uses: actions/checkout@v4
with:
repository: ${{ github.repository_owner }}/my-app-gitops
token: ${{ secrets.GITOPS_TOKEN }}
path: gitops
- name: Update image tag
run: |
cd gitops
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
sed -i "s|newTag:.*|newTag: ${SHORT_SHA}|" overlays/staging/kustomization.yaml
git config user.name "GitHub Actions"
git config user.email "[email protected]"
git add .
git diff --staged --quiet || git commit -m "chore: update image to ${SHORT_SHA}"
git push
The workflow does two things:
- Builds and pushes the Docker image to GHCR with the commit SHA as tag
- Updates the GitOps repository with the new image tag
Step 2: Set Up the GitOps Repository
Create your Kubernetes manifests using Kustomize for easy environment management.
Base Manifests
base/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 2
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: ghcr.io/your-org/my-app
ports:
- containerPort: 3000
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
base/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: my-app
spec:
selector:
app: my-app
ports:
- port: 80
targetPort: 3000
base/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
Environment Overlays
overlays/staging/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: staging
resources:
- ../../base
images:
- name: ghcr.io/your-org/my-app
newTag: latest
overlays/production/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
- ../../base
replicas:
- name: my-app
count: 3
images:
- name: ghcr.io/your-org/my-app
newTag: stable
Step 3: Install ArgoCD
Install ArgoCD on your Kubernetes cluster:
# Create namespace
kubectl create namespace argocd
# Install ArgoCD
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
# Wait for pods to be ready
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=argocd-server -n argocd --timeout=120s
Get the initial admin password:
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
Access the ArgoCD UI:
kubectl port-forward svc/argocd-server -n argocd 8080:443
# Visit https://localhost:8080 (username: admin)
Step 4: Create an ArgoCD Application
Create an ArgoCD Application that watches your GitOps repository.
argocd-application.yaml:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-app-staging
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-org/my-app-gitops
targetRevision: HEAD
path: overlays/staging
destination:
server: https://kubernetes.default.svc
namespace: staging
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Apply it:
kubectl apply -f argocd-application.yaml
Key settings:
- automated.prune: Removes resources deleted from Git
- automated.selfHeal: Reverts manual changes to match Git
- CreateNamespace: Automatically creates the namespace if missing
Step 5: Configure Secrets
Add these secrets to your application repository (Settings → Secrets → Actions):
| Secret | Description |
|---|---|
GITOPS_TOKEN |
Personal access token with write access to GitOps repo |
The GITHUB_TOKEN is automatically provided for GHCR access.
Creating the GitOps Token
- Go to GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens
- Create a token with:
- Repository access: Select your GitOps repository
- Permissions: Contents (Read and write)
- Copy the token and add it as
GITOPS_TOKENsecret
The Complete Flow
Here's what happens when you push code:
Timeline:
0s ──▶ Developer pushes code to main
30s ──▶ GitHub Actions starts build job
2min ──▶ Docker image built and pushed to GHCR
2.5min ──▶ GitOps repo updated with new tag
5min ──▶ ArgoCD detects change and syncs
6min ──▶ New version deployed and healthy ✓
- Push to main → GitHub Actions triggers
- Build & Test → Docker image is built
- Push to GHCR → Image tagged with commit SHA
- Update GitOps Repo → Staging kustomization updated
- ArgoCD Syncs → Detects change within ~3 minutes
- Deploy → Applies new manifests to cluster
- Health Check → Verifies deployment is healthy
Promoting to Production
For production deployments, manually update the production overlay:
cd my-app-gitops
# Get the tested tag from staging
STAGING_TAG=$(grep 'newTag:' overlays/staging/kustomization.yaml | awk '{print $2}')
# Update production
sed -i "s|newTag:.*|newTag: ${STAGING_TAG}|" overlays/production/kustomization.yaml
git add .
git commit -m "promote: ${STAGING_TAG} to production"
git push
Or better yet, create a pull request for production changes to require team approval.
Rollback with Git
GitOps makes rollbacks trivial—just revert the Git commit:
cd my-app-gitops
git revert HEAD
git push
# ArgoCD automatically rolls back the deployment
Or use ArgoCD's UI to sync to a previous commit:
argocd app sync my-app-staging --revision <previous-commit-sha>
Monitoring with ArgoCD
ArgoCD provides built-in status monitoring:
# Check application status
argocd app get my-app-staging
# View sync history
argocd app history my-app-staging
# Manual sync if auto-sync is disabled
argocd app sync my-app-staging
# Check for drift
argocd app diff my-app-staging
Best Practices
- Separate CI and CD: CI builds images, CD deploys them
- Never auto-sync production: Require manual promotion or PR approval
- Use semantic versioning: Tag releases for easy identification
- Enable selfHeal for staging: Fast feedback, catch configuration drift
- Keep secrets out of Git: Use Sealed Secrets or External Secrets Operator
- Monitor sync status: Set up alerts for failed syncs
Troubleshooting
ArgoCD Not Syncing
# Check application status
argocd app get my-app-staging
# View detailed sync status
argocd app sync-status my-app-staging
# Check ArgoCD logs
kubectl logs -n argocd -l app.kubernetes.io/name=argocd-repo-server
Image Pull Errors
If your cluster can't pull from GHCR, create an image pull secret:
kubectl create secret docker-registry ghcr-secret \
--docker-server=ghcr.io \
--docker-username=YOUR_GITHUB_USERNAME \
--docker-password=YOUR_GITHUB_TOKEN \
-n staging
Add to your deployment:
spec:
imagePullSecrets:
- name: ghcr-secret
Sync Conflicts
If someone manually changed resources in the cluster:
# Force sync to override manual changes
argocd app sync my-app-staging --force
Alternative: Flux CD
Flux is another popular GitOps tool with similar capabilities:
flux bootstrap github \
--owner=your-org \
--repository=my-app-gitops \
--path=overlays/staging \
--personal
Both ArgoCD and Flux are CNCF projects with active communities. ArgoCD has a better UI; Flux integrates more tightly with Git.
Summary
With this GitOps setup, you have:
- Declarative deployments: Everything defined in Git
- Automated sync: ArgoCD handles deployment automatically
- Easy rollbacks: Just revert the Git commit
- Multi-environment support: Staging and production with Kustomize overlays
- Audit trail: Git history shows who deployed what and when
- Self-healing: Cluster automatically reverts unauthorized changes
GitOps is the industry standard for Kubernetes deployments. No more SSH scripts or manual kubectl commands—your Git repository becomes the single source of truth, and your cluster stays in sync automatically.
These amazing companies help us create free, high-quality DevOps content for the community
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?