DEV Community

Mustafa ERBAY
Mustafa ERBAY

Posted on • Originally published at mustafaerbay.com.tr

GitOps vs Push-Based CI/CD: Which One for Consulting?

When comparing GitOps and push-based CI/CD, I prefer to stay away from the rosy pictures painted by theoretical articles. In the field, when a deployment fails at 3:00 AM, concepts like "Zero Trust" or "Single Source of Truth" quickly fly out the window. Having built dozens of infrastructures of all sizes over the last 20 years and survived hundreds of deployment crises, I wanted to lay out the real-world implications of these two approaches in consulting projects.

I have implemented both models multiple times in client projects and large-scale systems I've developed myself. I don't really care which one is considered more "modern"; my only metrics are how much it reduces operational overhead, how much sleep it saves the team, and how much it optimizes the bill. Let's put theory aside and focus directly on the realities of production environments.

The Realities of the Push-Based CI/CD Approach in the Field

In traditional push-based CI/CD models (for example, scenarios where we connect directly to the target server using GitLab CI or GitHub Actions to deploy), the trigger is always an external pipeline tool. Code is pushed, tests run, and then the runner hits the API or SSH port of the target server to deploy the new image or code package.

This approach is a lifesaver, especially in non-Kubernetes bare-metal or hybrid deployments. However, the biggest weakness of this model is having to store the target servers' access credentials (SSH keys, API tokens, Kubernetes Kubeconfig files) on the CI/CD runners. In one client project, an external CI/CD service breach almost led to the leak of a Kubernetes admin-privileged kubeconfig file stored on the runner. Fortunately, due to egress filtering (IP restrictions), the attackers couldn't get inside, but since that day, I have questioned the security boundaries of push-based models much more rigorously.

Let's look at a typical GitLab CI push deploy step below. In this code snippet, the runner connects to the target server via SSH to trigger Docker Compose commands:

# .gitlab-ci.yml - Classic Push-Based Deploy
deploy_to_production:
  stage: deploy
  image: alpine:3.19
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
  script:
    - ssh deploy_user@$PRODUCTION_IP "cd /opt/app && docker compose pull && docker compose up -d"
  only:
    - main
Enter fullscreen mode Exit fullscreen mode

Does this code work? Yes, I have spun up hundreds of projects this way over the years. However, the biggest risk here is the security of the $SSH_PRIVATE_KEY variable. Additionally, you have to open the SSH port of the target server (usually 22, or another port if you changed it) to the outside world, or at least to the CI runner IPs. If you are using cloud-based runner services that don't provide static IPs, managing firewall rules becomes an absolute nightmare.

Meeting GitOps and the Anatomy of the Pull-Based Model

GitOps operates on a philosophy that is the exact opposite of the push-based model. The desired state of the infrastructure and applications is kept declaratively in a Git repository. An agent running inside the target system (such as ArgoCD or Flux v2) constantly monitors this Git repository. When a discrepancy (drift) occurs between the state in the Git repository and the actual state, the agent updates the target system to reconcile this difference.

This approach completely eliminates the need for inbound access from the outside. That means you can close your Kubernetes cluster's API port to the outside world. The ArgoCD agent inside the cluster establishes an outbound (egress) connection to the external Git repository to pull the changes. From a security perspective, this is a massive win.

However, GitOps also comes with its own complexities. For example, one of the biggest issues I faced while managing application states on ArgoCD 2.10+ was dynamic values in Helm charts breaking the synchronization loop. In the Kubernetes manifest file below, you can see how the GitOps agent continuously monitors the Git repository to sync the state:

# argocd-application.yaml - GitOps Pull-Based Model
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: production-erp-api
  namespace: argocd
spec:
  project: default
  source:
    repoURL: 'https://github.com/mustafaerbay/production-manifests.git'
    targetRevision: HEAD
    path: apps/erp-api
  destination:
    server: 'https://kubernetes.default.svc'
    namespace: production
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
Enter fullscreen mode Exit fullscreen mode

In this model, the application developer simply writes the code, builds the image, and pushes it to the registry. Then, they update the image tag in the manifest repository. ArgoCD detects this change and deploys the new image into the cluster. The developer or the CI pipeline doesn't need access permissions to the cluster. However, this decouples the CI pipeline and the CD process entirely. A successful CI pipeline run no longer means the application is actually deployed; it just means "a commit was pushed to the manifest repo."

ℹ️ Egress Control is Crucial

Outbound connections must be allowed for GitOps agents to access external Git repositories. In network-hardened environments, don't forget to whitelist your Git server's IP addresses on the proxy or firewall.

GitOps vs Push-Based CI/CD Selection Criteria in Consulting Practice

In the companies I consult for, I have seen time and again that there is no such thing as the "best technology", only "manageable technology with an appropriate budget and human resources." If you go to a startup and say, "Let's set up GitOps right away and manage our cluster declaratively with ArgoCD," you are doing them a disservice. The priority for that startup is to validate their product in the market quickly, not to waste time resolving Git repository drifts.

As I mentioned in my previous article [related: my VPS migration experience], operational simplicity is always more valuable than complex engineering fantasies. Here is a summary of the core metrics and trade-offs I use when making a decision:

Criterion Push-Based CI/CD (GitLab/GitHub Actions) GitOps (ArgoCD/Flux)
Initial Setup Cost Very Low (Ready in hours) High (Requires in-cluster agent and repo structure)
Security Boundary Weak (Highly privileged keys are given to the CI tool) Very Strong (Secure pull from inside out)
Kubernetes Dependency None (Supports VMs, Bare-metal, Serverless) Very High (Usually exclusive to the K8s world)
Rollback Speed Slow (Requires re-triggering the pipeline) Very Fast (Instant rollback via Git revert or UI)
Debugging Ease Easy (All logs are on a single pipeline screen) Hard (Need to identify if the error is in Git or the agent)
Team Learning Curve Very Low (Every developer knows how to push) High (Must know K8s manifests and GitOps workflow)

If the infrastructure of the company I consult for does not run entirely on Kubernetes, I rule out GitOps immediately. Trying to implement GitOps on bare-metal servers or simple Docker Compose setups is like using a sledgehammer to crack a nut. Although there are some "bare-metal GitOps" tools out there, their maintenance overhead far outweighs the benefits they provide.

Comparison in Terms of Security and Network Topology

When designing security architecture, I always think of the worst-case scenario. In a push-based model, if your CI runner is compromised, your entire production environment is at risk. For example, an attacker who gains access to the server running your GitLab Runner can grab the AWS IAM keys or Kubernetes configurations stored in the environment variables and delete your entire infrastructure.

In the GitOps model, things are different. Even if an attacker compromises the manifest repository, what they can do is restricted by the boundaries of those manifests. ArgoCD inside Kubernetes can only perform operations within its own service account permissions. Furthermore, from a network topology perspective, it fits much better into a zero-trust architecture.

In the diagram below, you can compare the network traffic flow of the two models:

PUSH-BASED MODEL:
[CI/CD Runner] ---------- (Inbound: Port 22/6443) ----------> [Production Server]
(A dangerous inbound access path must be opened from the outside)

GITOPS (PULL-BASED) MODEL:
[Production Server (ArgoCD Agent)] ---------- (Outbound: Port 443) ----------> [Git Repo]
(Inbound access from the outside is completely closed, only secure outbound requests are made)
Enter fullscreen mode Exit fullscreen mode

To take security a step further, it's also necessary to apply Linux kernel hardening steps. For example, I use AppArmor or SELinux profiles to restrict the privileges of the pods or containers running the GitOps agents in production. I also blacklist unnecessary modules at the kernel level to prevent potential container breakout attacks.

⚠️ The Secret Management Trap

The most common mistake when using GitOps is committing sensitive data (secrets) like database passwords or API keys as plain text to the Git repository. Everything in the Git repository must be encrypted. For this, Mozilla SOPS or HashiCorp Vault integration is essential.

A Migration Story from a Production ERP and Lessons Learned

At one point, we were modernizing the infrastructure of a production ERP that we used heavily. The system consisted of a PostgreSQL database, a FastAPI backend, and Vue frontend components. Initially, everything was managed with classic push-based CI/CD. GitLab runners triggered Ansible playbooks on every commit to update the servers.

However, the operator screens on the production line had to run 24/7 without interruption. On April 28th, at exactly 2:22 PM, during a deployment, the Ansible playbook's SSH connection dropped due to a momentary network fluctuation. The deployment halted halfway. The system fell into a "half-deployed" state; the database migration had been executed, but only a portion of the backend code had been updated. Operator screens froze, and the production planning module crashed. Shipments at the factory ground to a halt for about 45 minutes.

After this crisis, I decided to migrate the entire system to Kubernetes and move the CD process to the GitOps model with ArgoCD. This migration process provided us with the following concrete benefits:

  1. Rollback Time: Previously, we had to wait for the GitLab pipeline to run again to roll back a faulty deployment (about 8 minutes). After moving to GitOps, we reduced the rollback time to 12 seconds with a single click from the ArgoCD interface (or by reverting to the previous commit via Git).
  2. System Stability: Network drops or runner crashes no longer affected deployments. Since ArgoCD runs inside the cluster, it constantly synchronized the state independently of network fluctuations.
  3. Log Monitoring: When an issue occurred, we could catch the error directly through Kubernetes events without hitting journald limits.

The simple journalctl monitoring output we used to track the reduction in error logs and system stability after the migration was as follows:

# Checking synchronization logs after GitOps migration
$ journalctl -u k3s -n 100 --no-pager | grep -i "argocd"
May 21 10:15:32 node-1 k3s[1204]: info: argocd-application-controller: Comparing app 'production-erp-api' status
May 21 10:15:35 node-1 k3s[1204]: info: argocd-application-controller: Resource 'apps/Deployment/production/erp-api' is Synced
May 21 10:15:35 node-1 k3s[1204]: info: argocd-application-controller: Reintegration successful, drift resolved in 342ms
Enter fullscreen mode Exit fullscreen mode

These log lines were the greatest proof that the system's self-healing mechanism was working like clockwork. Without requiring manual intervention, any incorrect external change was overwritten and brought back to the correct state within seconds.

Which One to Choose When?

If you ask me as a consultant, "Mustafa, which one should we use in our project?", I will ask you these direct questions:

  1. Is your infrastructure Kubernetes? If the answer is no, stay away from the GitOps fantasy. Continue with a push-based model (GitLab CI, GitHub Actions, Jenkins). A simple SSH-based deploy or Docker Compose trigger will more than do the job. Just say "good enough" and move on; don't suffocate the system with over-engineering.
  2. Do you have a dedicated platform/DevOps engineer on your team? Maintaining GitOps tools, drift analysis, and secret management requires serious expertise. If your team consists only of developers, the complexity brought by GitOps will slow them down. Debugging in a push-based model is much more natural for developers.
  3. **

Top comments (0)