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
| Requirement | Why |
|---|---|
| Self-service | Developers shouldn’t need me to deploy their services |
| GitOps-native | All state in Git - auditable, rollback-able |
| Environment-aware | UAT and production with different configs |
| Full lifecycle | Create, update, and delete - not just deploy |
| Low barrier | No 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
- Developer triggers a GitHub Actions workflow via
workflow_dispatch(a form in the GitHub UI) - Workflow generates/modifies Helm values and ingress config using
yq - Changes are committed to the GitOps repo
- ArgoCD detects changes and syncs to EKS
- 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
| Aspect | How It’s Handled |
|---|---|
| State | All config in Git - single source of truth |
| Audit trail | Git history shows who changed what, when |
| Rollback | git revert + ArgoCD sync |
| Self-service | GitHub UI forms - no CLI needed |
| Consistency | Templates ensure uniform configuration |
| Multi-environment | Environment 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:
| Action | API Call |
|---|---|
| Create service | POST /dispatches → create-service.yaml |
| Update resources | POST /dispatches → update-service.yaml |
| Delete service | POST /dispatches → delete-service.yaml |
| Check status | GET /runs → workflow run status |
| View logs | GET /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
-
GitHub Actions is a decent control plane.
workflow_dispatchgives you forms, inputs, and audit trails for free. -
yq is the unsung hero. YAML manipulation in CI pipelines becomes trivial. Learn it.
-
ArgoCD’s
--cascadeis essential. When deleting Applications, it cleans up all associated Kubernetes resources. -
GitOps makes rollback trivial. Bad deployment?
git revertand ArgoCD syncs the previous state automatically. -
Self-service changes everything. Once developers can deploy without waiting for me, velocity increases and I stop being a bottleneck.
-
You don’t need a “platform” to have a platform. Sometimes good conventions and existing tools beat purpose-built solutions.
-
The GitHub API is your escape hatch. When the GitHub UI isn’t enough, build your own.
workflow_dispatchis 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.