TL;DR: Managing 50+ TCP services manually wasn’t sustainable. Built a self-service system using GitHub Actions for lifecycle management (create, update, delete) and ArgoCD for deployment. Fill out a form, click a button, service goes live. Accidentally built an Internal Developer Platform.

Note: This post references ingress-nginx, which reaches end-of-life in March 2026. The GitOps patterns here work with any ingress/gateway controller - just swap the config templates.


The Problem After the Solution

In my previous post, I covered the architecture for running 50+ TCP services on EKS with multiple NLBs. That solved the “how do we run these things” problem.

Now I had a new one: how do we manage them without losing our minds?

Each service needed:

  • A Helm values file with unique configuration
  • Assignment to a specific NLB (based on port allocation)
  • An ArgoCD Application to deploy it
  • TCP port mapping in the ingress-nginx config
  • Environment-specific secrets and database connections

Doing this manually for 50+ services across UAT and production? That’s a recipe for burnout and mistakes.

What I Wanted

RequirementWhy
Self-serviceDevelopers shouldn’t need me to deploy their services
GitOps-nativeAll state in Git - auditable, rollback-able
Environment-awareUAT and production with different configs
Full lifecycleCreate, update, and delete - not just deploy
Low barrierNo new tools to learn - use what we already have

The Architecture

GitHub Actions as the “control plane”, ArgoCD as the “deployment plane”:

flowchart TB
    subgraph "Developer Self-Service"
        DEV[Developer] --> |"workflow_dispatch"| GHA[GitHub Actions]
    end

    subgraph "Lifecycle Workflows"
        GHA --> CREATE[Create Service]
        GHA --> UPDATE[Update Service]
        GHA --> DELETE[Delete Service]
    end

    subgraph "GitOps Repository"
        CREATE --> |"generates"| VALUES[Helm Values File]
        CREATE --> |"updates"| NGINX[ingress-nginx Config]
        CREATE --> |"creates"| ARGO_APP[ArgoCD Application]
        UPDATE --> VALUES
        DELETE --> |"removes"| VALUES
        DELETE --> |"cleans"| NGINX
    end

    subgraph "ArgoCD"
        ARGO_APP --> |"syncs"| EKS[EKS Cluster]
        VALUES --> |"helm values"| EKS
    end

    style GHA fill:#ffd93d,color:#000
    style ARGO_APP fill:#4ecdc4,color:#000
    style EKS fill:#96ceb4,color:#000

The Flow

flowchart LR
    A[Fill Form] --> B[Run Workflow]
    B --> C[Generate Config]
    C --> D[Commit to Git]
    D --> E[ArgoCD Syncs]
    E --> F[Service Live]

    style A fill:#e8e8e8,color:#000
    style B fill:#ffd93d,color:#000
    style D fill:#45b7d1,color:#000
    style E fill:#4ecdc4,color:#000
    style F fill:#96ceb4,color:#000
  1. Developer triggers a GitHub Actions workflow via workflow_dispatch (a form in the GitHub UI)
  2. Workflow generates/modifies Helm values and ingress config using yq
  3. Changes are committed to the GitOps repo
  4. ArgoCD detects changes and syncs to EKS
  5. Service is live (or updated, or removed)

The Workflows

Create Service

The create workflow does the heavy lifting - it takes form inputs and generates all necessary configuration:

flowchart TD
    subgraph "Inputs (GitHub Form)"
        ENV[Environment]
        SVC[Service Name & ID]
        PORT[TCP Port]
        DB[Database Name]
        NLB[Ingress Number]
        EXT[External Access?]
    end

    subgraph "Job 1: Generate Config"
        ENV --> COPY[Copy template]
        SVC --> YQ1[yq: Fill values]
        PORT --> YQ1
        DB --> YQ1
        NLB --> YQ2[yq: Add to ingress]
        EXT --> |"if true"| YQ2
        YQ1 --> COMMIT[Commit to Git]
        YQ2 --> COMMIT
    end

    subgraph "Job 2: Deploy"
        COMMIT --> ARGO[Create ArgoCD App]
        ARGO --> SYNC[Sync to cluster]
    end

    style YQ1 fill:#ffd93d,color:#000
    style YQ2 fill:#ffd93d,color:#000
    style ARGO fill:#4ecdc4,color:#000
    style SYNC fill:#96ceb4,color:#000
on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options:
          - uat
          - production
      service_id:
        description: 'Unique service identifier'
        required: true
        type: string
      service_name:
        description: 'Service name'
        required: true
        type: string
      service_port:
        description: 'TCP port for the service'
        required: true
        type: string
      service_db_name:
        description: 'Database name'
        required: true
        type: string
      external_access:
        description: 'Enable external access via ingress'
        required: true
        type: boolean
      ingress_number:
        description: 'Ingress controller number (determines which NLB)'
        type: choice
        options:
          - "01"
          - "02"
          - "0n"


env:
  CHART_PATH: charts/services
  ECR_REGISTRY: ${{ secrets.ECR_REGISTRY }}
  ARGO_PROJECT: default
  AWS_REGION: ${{ secrets.AWS_REGION }}

jobs:
  generate-new-service-yaml:
    runs-on: [self-hosted]
    steps:
      - uses: actions/checkout@v4.1.1

      - name: Generate new service file from template
        run: |
          VALUES_DIR="${{ env.CHART_PATH }}/values/${{ inputs.environment }}"
          SERVICE_FILE="${VALUES_DIR}/service-${{ inputs.service_id }}.yaml"
          cp service-template.yaml "${SERVICE_FILE}"

      - name: Update service values with yq
        uses: mikefarah/yq@v4.27.5
        env:
          VALUES_FILE: "${{ env.CHART_PATH }}/values/${{ inputs.environment }}/service-${{ inputs.service_id }}.yaml"
        with:
          cmd: |
            yq -i '.randomid = "${{ github.sha }}"' "${VALUES_FILE}"
            yq -i '.name = "${{ inputs.service_name }}"' "${VALUES_FILE}"
            yq -i '.image.repository = "${{ env.ECR_REGISTRY }}"' "${VALUES_FILE}"
            yq -i '.service.annotations."kubernetes.io/ingress.class" = "tcp${{ inputs.ingress_number }}"' "${VALUES_FILE}"
            yq -i '.tcpPort = "${{ inputs.service_port }}"' "${VALUES_FILE}"
            yq -i '.environmentVariables[0].value = "${{ secrets[inputs.environment] }}"' "${VALUES_FILE}"
            # ... additional environment variable mappings

      - name: Update ingress for external access
        if: ${{ inputs.external_access == 'true' }}
        uses: mikefarah/yq@v4.27.5
        with:
          cmd: |
            yq -i '.ingress-nginx.tcp.${{ inputs.service_port }} = "services/${{ inputs.service_name }}:${{ inputs.service_port }}"' \
              "charts/nginx/nginxtcp${{ inputs.ingress_number }}.yaml"

      - name: Commit and push changes
        uses: stefanzweifel/git-auto-commit-action@v5.0.0

  argo-generate-deploy:
    needs: generate-new-service-yaml
    runs-on: [self-hosted]
    steps:
      - uses: actions/checkout@v4.1.1

      - name: Update ArgoCD application manifest
        uses: mikefarah/yq@v4.27.5
        with:
          cmd: |
            yq -i '.metadata.name = "${{ inputs.service_name }}"' app.yaml
            yq -i '.spec.project = "${{ env.ARGO_PROJECT }}"' app.yaml
            yq -i '.spec.source.helm.valueFiles[0] = "values/${{ inputs.environment }}/service-${{ inputs.service_id }}.yaml"' app.yaml
            yq -i '.spec.source.helm.releaseName = "${{ inputs.service_name }}"' app.yaml

      - name: Configure kubeconfig for EKS
        run: |
          aws eks update-kubeconfig --name ${{ inputs.environment }}-cluster --region ${{ env.AWS_REGION }}
          kubectl config set-context --current --namespace=argocd

      - name: Create ArgoCD application
        uses: clowdhaus/argo-cd-action/@main
        with:
          version: 2.4.11
          command: |
            login ${{ secrets.ARGO_SERVER }} --username ${{ secrets.ARGO_USERNAME }} --password ${{ secrets.ARGO_PASSWORD }}
            app create "${{ inputs.service_name }}" -f app.yaml

The ArgoCD Application template (app.yaml):

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: # filled by workflow
spec:
  project: default
  source:
    repoURL: 'git@github.com:org/services.git'
    path: charts/services
    targetRevision: main
    helm:
      valueFiles: [] # filled by workflow
      releaseName:   # filled by workflow
  destination:
    server: 'https://kubernetes.default.svc'
    namespace: services
  syncPolicy:
    automated:
      prune: true    # Remove resources deleted from Git
      selfHeal: true # Revert manual cluster changes

Delete Service

Removal is the reverse - delete the ArgoCD app (which cascades to remove all Kubernetes resources), then clean up the Git config:

flowchart TD
    subgraph "Job 1: Destroy"
        LOGIN[Login to ArgoCD] --> DELETE[argocd app delete --cascade]
        DELETE --> |"Removes"| POD[Pods]
        DELETE --> |"Removes"| SVC[Services]
        DELETE --> |"Removes"| CFG[ConfigMaps]
    end

    subgraph "Job 2: Cleanup Git"
        DELETE --> YQ[Remove from ingress config]
        YQ --> RM[Delete values file]
        RM --> COMMIT[Commit changes]
    end

    style DELETE fill:#ff6b6b,color:#fff
    style YQ fill:#ffd93d,color:#000
    style COMMIT fill:#45b7d1,color:#000
on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options:
          - uat
          - production
      service_name:
        description: 'Service name to remove'
        required: true
        type: string
      service_id:
        description: 'Service ID (for values file cleanup)'
        required: false
        type: string
      service_port:
        description: 'TCP port (required if external access was enabled)'
        type: string
      external_access:
        description: 'Service has external access configured'
        type: boolean
      ingress_number:
        description: 'Ingress controller number'
        type: choice
        options: ["01", "02", "03", "04", "05"]

env:
  CHART_PATH: charts/services
  AWS_REGION: ${{ secrets.AWS_REGION }}

jobs:
  destroy-service:
    runs-on: [self-hosted]
    steps:
      - name: Configure kubeconfig for EKS
        run: |
          aws eks update-kubeconfig --name ${{ inputs.environment }}-cluster --region ${{ env.AWS_REGION }}
          kubectl config set-context --current --namespace=argocd

      - name: Delete ArgoCD application
        run: |
          argocd login ${{ secrets.ARGO_SERVER }} --username ${{ secrets.ARGO_USERNAME }} --password ${{ secrets.ARGO_PASSWORD }}
          argocd app delete ${{ inputs.service_name }} --cascade

  cleanup-config:
    needs: destroy-service
    if: ${{ inputs.external_access == 'true' }}
    runs-on: [self-hosted]
    steps:
      - uses: actions/checkout@v4.1.1

      - name: Remove port from ingress config
        uses: mikefarah/yq@v4.27.5
        with:
          cmd: |
            yq -i 'del(.ingress-nginx.tcp.${{ inputs.service_port }})' \
              "charts/nginx/nginxtcp${{ inputs.ingress_number }}.yaml"

      - name: Remove service values file
        if: ${{ inputs.service_id != '' }}
        run: |
          SERVICE_FILE="${{ env.CHART_PATH }}/values/${{ inputs.environment }}/service-${{ inputs.service_id }}.yaml"
          rm -f "${SERVICE_FILE}"
          git rm "${SERVICE_FILE}" || true

      - name: Commit changes
        uses: stefanzweifel/git-auto-commit-action@v5.0.0
        with:
          commit_message: "Remove service ${{ inputs.service_name }}"

The --cascade flag is critical - it tells ArgoCD to delete all Kubernetes resources the Application created, not just the Application CR itself.

Update Service

For configuration changes (resource limits, health probes, etc.), modify the values file and let ArgoCD handle the rolling update:

flowchart LR
    FORM[Update Form] --> YQ[yq modifies values]
    YQ --> BRANCH[Automation branch]
    BRANCH --> MERGE[Merge to main]
    MERGE --> ARGO[ArgoCD detects diff]
    ARGO --> ROLL[Rolling update]

    style FORM fill:#e8e8e8,color:#000
    style YQ fill:#ffd93d,color:#000
    style ARGO fill:#4ecdc4,color:#000
    style ROLL fill:#96ceb4,color:#000
name: Update Service Settings

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options:
          - uat
          - production
      service_id:
        description: 'Service ID to update'
        required: true
        type: string
      cpu_req:
        description: 'CPU request'
        default: "200m"
        type: string
      cpu_limit:
        description: 'CPU limit'
        default: "200m"
        type: string
      mem_req:
        description: 'Memory request'
        default: "200Mi"
        type: string
      mem_limit:
        description: 'Memory limit'
        default: "200Mi"
        type: string
      probe:
        description: 'Enable health probe'
        default: false
        type: boolean

env:
  CHART_PATH: charts/services
  DEFAULT_BRANCH: main

jobs:
  update-service-yaml:
    runs-on: [self-hosted]
    steps:
      - uses: actions/checkout@v4.1.1

      - name: Locate service values file
        id: filename
        run: |
          SERVICE_FILE="${{ env.CHART_PATH }}/values/${{ inputs.environment }}/service-${{ inputs.service_id }}.yaml"
          echo "file=${SERVICE_FILE}" >> $GITHUB_OUTPUT

      - name: Update service configuration
        uses: mikefarah/yq@v4.27.5
        with:
          cmd: |
            yq -i '.resources.requests.cpu = "${{ inputs.cpu_req }}"' "${{ steps.filename.outputs.file }}"
            yq -i '.resources.limits.cpu = "${{ inputs.cpu_limit }}"' "${{ steps.filename.outputs.file }}"
            yq -i '.resources.requests.memory = "${{ inputs.mem_req }}"' "${{ steps.filename.outputs.file }}"
            yq -i '.resources.limits.memory = "${{ inputs.mem_limit }}"' "${{ steps.filename.outputs.file }}"
            yq -i '.service.annotations."prometheus.io/health" = "${{ inputs.probe }}"' "${{ steps.filename.outputs.file }}"

      - name: Commit and merge
        uses: stefanzweifel/git-auto-commit-action@v5.0.0
        with:
          commit_message: "Update service-${{ inputs.service_id }}: cpu=${{ inputs.cpu_req }}, mem=${{ inputs.mem_req }}"

      - name: Cleanup stale automation branches
        uses: cbrgm/cleanup-stale-branches-action@v1
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          allowed-prefixes: automation/

The Repository Structure

gitops-repo/
├── charts/
│   ├── services/
│   │   ├── Chart.yaml
│   │   ├── templates/
│   │   └── values/
│   │       ├── uat/
│   │       │   ├── service-001.yaml
│   │       │   ├── service-002.yaml
│   │       │   └── ...
│   │       └── production/
│   │           └── ...
│   └── nginx/
│       ├── nginxtcp01.yaml   # NLB 1 config
│       ├── nginxtcp02.yaml   # NLB 2 config
│       └── ...
├── service-template.yaml     # Template for new services
├── app.yaml                  # ArgoCD Application template
└── .github/workflows/
    ├── create-service.yaml
    ├── delete-service.yaml
    └── update-service.yaml

Why This Works

AspectHow It’s Handled
StateAll config in Git - single source of truth
Audit trailGit history shows who changed what, when
Rollbackgit revert + ArgoCD sync
Self-serviceGitHub UI forms - no CLI needed
ConsistencyTemplates ensure uniform configuration
Multi-environmentEnvironment is just a workflow input

What I Accidentally Built

Looking back, this is a lightweight Internal Developer Platform:

flowchart TB
    subgraph "Enterprise IDP"
        UI1[Custom Web UI] --> API1[Platform API]
        API1 --> ORCH[Orchestration Layer]
        ORCH --> K8S1[Kubernetes]
    end

    subgraph "What I Built"
        UI2[GitHub Actions UI] --> API2[workflow_dispatch]
        API2 --> YQ[yq + Git commits]
        YQ --> ARGO[ArgoCD]
        ARGO --> K8S2[EKS]
    end

    style UI1 fill:#e8e8e8,color:#000
    style UI2 fill:#ffd93d,color:#000
    style ARGO fill:#4ecdc4,color:#000

No Backstage, no custom portal, no dedicated platform team. Just GitHub Actions forms, yq for YAML manipulation, and ArgoCD for deployment.

It’s not fancy, but it works. Developers fill out a form, click run, and their service is deployed. That’s all they care about.

Taking It Further: Build Your Own UI

Here’s the thing about workflow_dispatch - it’s just an API endpoint. The GitHub UI is convenient, but nothing stops you from building your own interface that calls the GitHub API directly.

flowchart LR
    subgraph "Current Setup"
        DEV1[Developer] --> GH_UI[GitHub Actions UI]
        GH_UI --> WF1[Workflow]
    end

    subgraph "Custom UI Option"
        DEV2[Developer] --> APP[Your App / Admin Panel]
        APP --> |"POST /dispatches"| GH_API[GitHub API]
        GH_API --> WF2[Workflow]
    end

    style GH_UI fill:#e8e8e8,color:#000
    style APP fill:#4ecdc4,color:#000
    style GH_API fill:#ffd93d,color:#000

The API Call

Triggering a workflow is a single POST request:

curl -X POST \
  -H "Accept: application/vnd.github.v3+json" \
  -H "Authorization: token $GITHUB_TOKEN" \
  https://api.github.com/repos/org/gitops-repo/actions/workflows/create-service.yaml/dispatches \
  -d '{
    "ref": "main",
    "inputs": {
      "environment": "production",
      "service_id": "042",
      "service_name": "payment-processor",
      "service_port": "9042",
      "service_db_name": "payments_db",
      "external_access": "true",
      "ingress_number": "01"
    }
  }'

What You Could Build

Imagine a simple admin panel in your existing management application:

ActionAPI Call
Create servicePOST /dispatchescreate-service.yaml
Update resourcesPOST /dispatchesupdate-service.yaml
Delete servicePOST /dispatchesdelete-service.yaml
Check statusGET /runs → workflow run status
View logsGET /runs/{id}/logs → deployment logs
flowchart TB
    subgraph "Your Management App"
        FORM[Service Form] --> VALIDATE[Validate Inputs]
        VALIDATE --> API_CALL[Call GitHub API]
        API_CALL --> POLL[Poll for Status]
        POLL --> NOTIFY[Notify User]
    end

    subgraph "GitHub Actions"
        API_CALL --> |"triggers"| WORKFLOW[Workflow Runs]
        WORKFLOW --> |"status"| POLL
    end

    subgraph "Cluster"
        WORKFLOW --> ARGO[ArgoCD Syncs]
        ARGO --> LIVE[Service Live]
    end

    style FORM fill:#e8e8e8,color:#000
    style API_CALL fill:#ffd93d,color:#000
    style ARGO fill:#4ecdc4,color:#000
    style LIVE fill:#96ceb4,color:#000

Why This Matters

  • Branding: Your UI, your UX, integrated into your existing tools
  • Validation: Add business logic before triggering workflows (e.g., check port availability, naming conventions)
  • Aggregation: Combine with other APIs - show service status, costs, logs in one place
  • Access control: Your app handles auth, GitHub just executes
  • Extensibility: Add new workflows, expose them through the same interface

The beauty of this approach is that GitHub Actions remains the execution engine - you’re not reinventing deployment pipelines. You’re just swapping the UI layer. All the GitOps benefits (audit trail, rollback, state in Git) stay intact.

Start with the GitHub UI, prove the workflows work, then build a custom interface when the need arises. The API is always there waiting.


Takeaways

  1. GitHub Actions is a decent control plane. workflow_dispatch gives you forms, inputs, and audit trails for free.

  2. yq is the unsung hero. YAML manipulation in CI pipelines becomes trivial. Learn it.

  3. ArgoCD’s --cascade is essential. When deleting Applications, it cleans up all associated Kubernetes resources.

  4. GitOps makes rollback trivial. Bad deployment? git revert and ArgoCD syncs the previous state automatically.

  5. Self-service changes everything. Once developers can deploy without waiting for me, velocity increases and I stop being a bottleneck.

  6. You don’t need a “platform” to have a platform. Sometimes good conventions and existing tools beat purpose-built solutions.

  7. The GitHub API is your escape hatch. When the GitHub UI isn’t enough, build your own. workflow_dispatch is just a REST endpoint - integrate it into any application.

The best platform is the one people actually use. Start with the GitHub UI, prove the concept, then extend via API when needed. The foundation stays the same either way.