Overview

In the previous post, I set up Harbor container registry, Argo Events, and Argo Workflows as the foundation of the IDP. This post covers integrating those components with ArgoCD and designing Helm chart-based project templates to turn them into an Internal Developer Platform (IDP) that can deploy projects from a single YAML file.

Internal Developer Platform Architecture

What Is an Internal Developer Platform?

What is an Internal Developer Platform (IDP)?

An Internal Developer Platform is a system that provides developers with an abstracted self-service interface to deploy and operate applications without directly configuring infrastructure and deployment pipelines. As a core deliverable of platform engineering, it aims to improve developer experience and reduce operational burden through standardized deployment processes.

Traditional CI/CD pipelines usually had to be set up separately for each project. What I wanted instead was a template-driven internal platform that could provision most of the surrounding infrastructure from a small configuration file. The version I built in this post worked like this:

  1. A developer pushes code to a Git repository.
  2. A GitHub webhook sends an event to Argo Events’ EventSource.
  3. Argo Events’ Sensor filters the event and triggers an Argo Workflow.
  4. Argo Workflows builds the code and pushes the container image to Harbor.
  5. When the workflow completes, it calls the GitHub API to update the project configuration file.
  6. ArgoCD detects the changed configuration file and deploys the application with the new image.

Project Template Design

I designed a Helm chart-based project template that I could reuse across multiple projects. The idea was to reduce a new project to a small YAML file while keeping the surrounding CI/CD wiring shared.

Project Template Requirements

For this homelab setup, I wanted the template to provide the following features:

  • Automated CI/CD Pipeline: Automatically builds and deploys when code changes in a GitHub repository.
  • Declarative Resource Management: Define applications, databases, and network settings in YAML files.
  • Secrets Management Integration: Safely manage passwords, API keys, and other secrets through Vault integration.
  • Multi-Application Support: Manage multiple applications and databases within a single project.

Git Repository Structure

I organized the GitOps repository with the following structure:

projects-gitops/
├── .github/workflows/
│   └── update-config.yaml
├── applicationset.yaml
├── chart/
│   ├── Chart.yaml
│   └── templates/
│       ├── app/
│       │   ├── ci/
│       │   │   ├── eventbus.yaml
│       │   │   ├── eventsource.yaml
│       │   │   ├── sensor.yaml
│       │   │   └── workflow-template.yaml
│       │   ├── deployment.yaml
│       │   ├── service.yaml
│       │   └── ingressroute.yaml
│       └── db/
│           ├── statefulset.yaml
│           └── service.yaml
└── projects/
    ├── project-a.yaml
    ├── project-b.yaml
    └── ...

In this structure, the chart/ directory contains the Helm chart shared by all projects, and the projects/ directory contains the configuration files for each project. Deploying a new project was as simple as adding a YAML file to the projects/ directory.

ApplicationSet Configuration

What is ApplicationSet?

ApplicationSet is an ArgoCD feature that uses templates and generators to automatically create and manage multiple Applications. It can dynamically create Applications based on file lists in Git repositories, directory structures, cluster lists, and more, enabling efficient management of large-scale multi-project environments.

The applicationset.yaml file looked like this:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
    name: projects-apps
    namespace: argocd
spec:
    goTemplate: true
    goTemplateOptions: ["missingkey=error"]
    generators:
        - git:
              repoURL: https://github.com/injunweb/projects-gitops.git
              revision: HEAD
              files:
                  - path: "projects/*.yaml"
    template:
        metadata:
            name: '{{.path.filenameNormalized | trimSuffix ".yaml"}}'
            namespace: argocd
        spec:
            project: default
            source:
                repoURL: https://github.com/injunweb/projects-gitops.git
                targetRevision: HEAD
                path: chart
                plugin:
                    name: argocd-vault-plugin-helm
                    env:
                        - name: HELM_ARGS
                          value: >-
                              -f ../projects/{{.path.filename}}
                              --set project={{.path.filenameNormalized | trimSuffix ".yaml"}}
            destination:
                server: https://kubernetes.default.svc
                namespace: '{{.path.filenameNormalized | trimSuffix ".yaml"}}'
            syncPolicy:
                automated:
                    prune: true
                    selfHeal: true
                syncOptions:
                    - CreateNamespace=true

This ApplicationSet uses a Git generator configured with the projects/*.yaml files pattern and automatically creates an ArgoCD Application for each file. The filename, minus the .yaml extension, becomes the project name and namespace. Secrets stored in Vault are then injected through the ArgoCD Vault Plugin.

Project Configuration File Structure

Each project in this setup is described with a YAML file that looks like this:

applications:
    - name: api
      git:
          type: github
          owner: myorg
          repo: my-api
          branch: main
          hash: ~
      port: 8080
      domains:
          - api.example.com

    - name: frontend
      git:
          type: github
          owner: myorg
          repo: my-frontend
          branch: main
          hash: ~
      port: 80
      domains:
          - www.example.com
          - example.com

databases:
    - name: mysql
      type: mysql
      version: "8.0"
      port: 3306
      size: 5Gi

    - name: redis
      type: redis
      version: "7.0"
      port: 6379
      size: 1Gi

Some of the more important fields in this configuration file are:

  • applications[].git.hash: The Git commit hash that the CI pipeline will build and deploy. It is initially empty and automatically updated when a build succeeds. Deployments are only created when this value exists.
  • applications[].domains: A list of domains for accessing the application. A Traefik IngressRoute is created for each domain.
  • databases[]: A list of databases to use in the project, supporting MySQL, PostgreSQL, Redis, and MongoDB.

CI Pipeline Templates

For the CI side, I used Argo Events and Argo Workflows together and kept the pipeline pieces as reusable Helm templates.

EventBus Template

A template that creates an independent event bus for each project:

apiVersion: argoproj.io/v1alpha1
kind: EventBus
metadata:
    name: {{ $.Values.project }}-ci-eventbus
    namespace: {{ $.Values.project }}
spec:
    nats:
        native:
            replicas: 3
            auth: none
            antiAffinity: false

This EventBus runs 3 NATS replicas for high availability and gives each project its own event transport layer.

EventSource Template

An EventSource template that receives GitHub webhooks:

{{- range $app := .Values.applications }}
---
apiVersion: argoproj.io/v1alpha1
kind: EventSource
metadata:
    name: {{ $.Values.project }}-{{ $app.name }}-github-eventsource
    namespace: {{ $.Values.project }}
spec:
    eventBusName: {{ $.Values.project }}-ci-eventbus
    template:
        serviceAccountName: {{ $.Values.project }}-ci-workflow-sa
    service:
        ports:
            - port: 12000
              targetPort: 12000
              name: webhook
    github:
        {{ $.Values.project }}-{{ $app.name }}-github-trigger:
            repositories:
                - owner: {{ $app.git.owner }}
                  names:
                      - {{ $app.git.repo }}
            webhook:
                endpoint: /{{ $.Values.project }}-{{ $app.name }}
                port: "12000"
                method: POST
                url: https://webhook.injunweb.com
            events:
                - push
            apiToken:
                name: {{ $.Values.project }}-github-access-secret
                key: token
            insecure: false
            active: true
            contentType: json
{{- end }}

This template creates an EventSource for each application defined in the project configuration and listens for push events from its GitHub repository. The webhook.url value is the externally accessible endpoint that GitHub sends events to.

Sensor Template

A Sensor template that filters events and triggers workflows:

{{- range $app := .Values.applications }}
---
apiVersion: argoproj.io/v1alpha1
kind: Sensor
metadata:
    name: {{ $.Values.project }}-{{ $app.name }}-github-workflow-sensor
    namespace: {{ $.Values.project }}
spec:
    eventBusName: {{ $.Values.project }}-ci-eventbus
    template:
        serviceAccountName: {{ $.Values.project }}-ci-workflow-sa
    dependencies:
        - name: github-dep
          eventSourceName: {{ $.Values.project }}-{{ $app.name }}-github-eventsource
          eventName: {{ $.Values.project }}-{{ $app.name }}-github-trigger
          filters:
              data:
                  - path: body.ref
                    type: string
                    comparator: "="
                    value:
                        - "refs/heads/{{ $app.git.branch }}"
    triggers:
        - template:
              name: workflow-trigger
              k8s:
                  operation: create
                  source:
                      resource:
                          apiVersion: argoproj.io/v1alpha1
                          kind: Workflow
                          metadata:
                              generateName: {{ $.Values.project }}-{{ $app.name }}-build-workflow-
                          spec:
                              arguments:
                                  parameters:
                                      - name: git_sha
                              workflowTemplateRef:
                                  name: {{ $.Values.project }}-{{ $app.name }}-build-workflow-template
                  parameters:
                      - src:
                            dependencyName: github-dep
                            dataKey: body.after
                        dest: spec.arguments.parameters.0.value
          retryStrategy:
              steps: 3
{{- end }}

This Sensor filters only push events to specific branches (e.g., main, develop) in the filters.data section, and when matching events occur, it creates a Workflow by referencing the WorkflowTemplate. The body.after value (the commit hash after the push) is passed as a workflow parameter.

WorkflowTemplate

A WorkflowTemplate that defines the build step and updates the project config afterward:

{{- range $app := .Values.applications }}
---
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
    name: {{ $.Values.project }}-{{ $app.name }}-build-workflow-template
    namespace: {{ $.Values.project }}
spec:
    serviceAccountName: {{ $.Values.project }}-ci-workflow-sa
    entrypoint: build
    arguments:
        parameters:
            - name: git_sha
              description: "Git commit hash"
    volumes:
        - name: docker-config
          secret:
              secretName: registry-secret
              items:
                  - key: .dockerconfigjson
                    path: config.json
    templates:
        - name: build
          dag:
              tasks:
                  - name: build
                    template: build-container
                    arguments:
                        parameters:
                            - name: sha
                              value: "{{`{{workflow.parameters.git_sha}}`}}"
                  - name: update-config
                    template: update-config
                    dependencies: [build]
                    arguments:
                        parameters:
                            - name: sha
                              value: "{{`{{workflow.parameters.git_sha}}`}}"

        - name: build-container
          inputs:
              parameters:
                  - name: sha
          hostAliases:
              - ip: "192.168.0.200"
                hostnames:
                    - "harbor.injunweb.com"
          container:
              image: gcr.io/kaniko-project/executor:latest
              args:
                  - "--context=git://github.com/{{ $app.git.owner }}/{{ $app.git.repo }}.git#refs/heads/{{ $app.git.branch }}#{{`{{inputs.parameters.sha}}`}}"
                  - "--dockerfile=Dockerfile"
                  - "--destination=harbor.injunweb.com/injunweb/{{ $.Values.project }}-{{ $app.name }}:{{`{{inputs.parameters.sha}}`}}"
                  - "--destination=harbor.injunweb.com/injunweb/{{ $.Values.project }}-{{ $app.name }}:latest"
                  - "--cache=true"
                  - "--cache-repo=harbor.injunweb.com/injunweb/cache"
              env:
                  - name: GIT_USERNAME
                    valueFrom:
                        secretKeyRef:
                            name: {{ $.Values.project }}-github-access-secret
                            key: username
                  - name: GIT_PASSWORD
                    valueFrom:
                        secretKeyRef:
                            name: {{ $.Values.project }}-github-access-secret
                            key: token
              volumeMounts:
                  - name: docker-config
                    mountPath: /kaniko/.docker/

        - name: update-config
          inputs:
              parameters:
                  - name: sha
          container:
              image: curlimages/curl:latest
              command: ["/bin/sh", "-c"]
              args:
                  - |
                      curl -X POST https://api.github.com/repos/injunweb/projects-gitops/dispatches \
                        -H "Accept: application/vnd.github.v3+json" \
                        -H "Authorization: Bearer $GITHUB_TOKEN" \
                        -d '{
                          "event_type": "config-api",
                          "client_payload": {
                            "path": "projects/{{$.Values.project}}/applications/{{$app.name}}",
                            "action": "apply",
                            "spec": {
                              "git": {
                                "hash": "'"{{`{{inputs.parameters.sha}}`}}"'"
                              }
                            }
                          }
                        }'
              env:
                  - name: GITHUB_TOKEN
                    valueFrom:
                        secretKeyRef:
                            name: {{ $.Values.project }}-github-access-secret
                            key: token
{{- end }}

The key components of this WorkflowTemplate are:

  • DAG Template: Defines two tasks, build and update-config, as a DAG with dependencies so that the configuration update only runs after a successful build.
  • Kaniko: A tool for building images inside containers without a Docker daemon, allowing image builds without privilege escalation. Caching is enabled to reduce build times.
  • GitHub API Call: When the build succeeds, it triggers a repository_dispatch event to update the git.hash value in the project configuration file.

CD Pipeline Templates

When the CI pipeline updates the project configuration file, ArgoCD picks up the change and rolls out the new image.

Deployment Template

A Deployment template for deploying applications:

{{- range $app := .Values.applications }}
{{- if $app.git.hash }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
    name: {{ $app.name }}-app
    namespace: {{ $.Values.project }}
spec:
    replicas: 1
    strategy:
        type: RollingUpdate
        rollingUpdate:
            maxSurge: 1
            maxUnavailable: 0
    selector:
        matchLabels:
            app: {{ $app.name }}-app
    template:
        metadata:
            labels:
                app: {{ $app.name }}-app
        spec:
            affinity:
                podAntiAffinity:
                    preferredDuringSchedulingIgnoredDuringExecution:
                        - weight: 100
                          podAffinityTerm:
                              labelSelector:
                                  matchExpressions:
                                      - key: app
                                        operator: In
                                        values:
                                            - {{ $app.name }}-app
                              topologyKey: "kubernetes.io/hostname"
            terminationGracePeriodSeconds: 120
            containers:
                - name: {{ $app.name }}-app
                  image: harbor.injunweb.com/injunweb/{{ $.Values.project }}-{{ $app.name }}:{{ $app.git.hash }}
                  lifecycle:
                      preStop:
                          exec:
                              command: ["/bin/sh", "-c", "sleep 10"]
                  ports:
                      - containerPort: {{ $app.port }}
                  readinessProbe:
                      tcpSocket:
                          port: {{ $app.port }}
                      initialDelaySeconds: 20
                      periodSeconds: 10
                      successThreshold: 3
                  envFrom:
                      - secretRef:
                            name: {{ $.Values.project }}-{{ $app.name }}-secret
                            optional: true
            imagePullSecrets:
                - name: registry-secret
{{- end }}
{{- end }}

The key point of this template is the {{- if $app.git.hash }} condition, which ensures that the Deployment is only created when the git.hash value is set. This guarantees that deployment only occurs after the CI pipeline has completed successfully.

The main features of the Deployment template are:

  • Rolling Update: The maxSurge: 1, maxUnavailable: 0 settings implement zero-downtime deployment.
  • Pod Anti-Affinity: Distributes Pods of the same application across different nodes to improve availability.
  • Graceful Shutdown: The preStop hook and 120-second termination grace period allow existing connections to complete normally.

IngressRoute Template

An IngressRoute template that provides external access to applications:

{{- range $app := .Values.applications }}
{{- if $app.git.hash }}
{{- range $domain := $app.domains }}
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
    name: {{ $.Values.project }}-{{ $app.name }}-{{ $domain | replace "." "-" }}-route
    namespace: {{ $.Values.project }}
spec:
    entryPoints:
        - web
        - websecure
    routes:
        - match: Host(`{{ $domain }}`)
          kind: Rule
          services:
              - name: {{ $app.name }}
                port: {{ $app.port }}
{{- end }}
{{- end }}
{{- end }}

This template creates a Traefik IngressRoute for each domain defined in the project configuration, using the web and websecure entry points for HTTP and HTTPS traffic.

Database StatefulSet Template

A StatefulSet template for database deployment:

{{- range $db := .Values.databases }}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
    name: {{ $.Values.project }}-{{ $db.name }}-db
    namespace: {{ $.Values.project }}
spec:
    serviceName: {{ $db.name }}
    selector:
        matchLabels:
            app: {{ $.Values.project }}-{{ $db.name }}-db
    template:
        metadata:
            labels:
                app: {{ $.Values.project }}-{{ $db.name }}-db
        spec:
            containers:
                - name: {{ $db.name }}
                  image: {{ $db.type }}:{{ $db.version }}
                  {{- if eq $db.type "mysql" }}
                  env:
                      - name: MYSQL_DATABASE
                        value: {{ $db.name }}_db
                      - name: MYSQL_USER
                        value: {{ $db.name }}_user
                      - name: MYSQL_PASSWORD
                        valueFrom:
                            secretKeyRef:
                                name: {{ $.Values.project }}-{{ $db.name }}-secret
                                key: password
                      - name: MYSQL_ROOT_PASSWORD
                        valueFrom:
                            secretKeyRef:
                                name: {{ $.Values.project }}-{{ $db.name }}-secret
                                key: password
                  {{- else if eq $db.type "redis" }}
                  args: ["--requirepass", "$(REDIS_PASSWORD)"]
                  env:
                      - name: REDIS_PASSWORD
                        valueFrom:
                            secretKeyRef:
                                name: {{ $.Values.project }}-{{ $db.name }}-secret
                                key: password
                  {{- end }}
                  ports:
                      - containerPort: {{ $db.port }}
                  volumeMounts:
                      - name: {{ $.Values.project }}-{{ $db.name }}-data
                        mountPath: {{- if eq $db.type "mysql" }} /var/lib/mysql
                                   {{- else if eq $db.type "redis" }} /data
                                   {{- else if eq $db.type "postgres" }} /var/lib/postgresql/data
                                   {{- else if eq $db.type "mongodb" }} /data/db
                                   {{- end }}
    volumeClaimTemplates:
        - metadata:
              name: {{ $.Values.project }}-{{ $db.name }}-data
          spec:
              accessModes: ["ReadWriteOnce"]
              resources:
                  requests:
                      storage: {{ $db.size }}
{{- end }}

This template supports four database types: MySQL, PostgreSQL, Redis, and MongoDB. It also sets the environment variables and volume mount paths each one needs.

GitHub Actions Configuration Update Workflow

To update the project configuration files, I used the following GitHub Actions workflow from the CI pipeline:

name: Configuration API

on:
    repository_dispatch:
        types: [config-api]

jobs:
    handle-request:
        runs-on: ubuntu-latest
        concurrency:
            group: config-update
            cancel-in-progress: false
        steps:
            - uses: actions/checkout@v3

            - name: Install yq
              run: |
                  wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/local/bin/yq
                  chmod +x /usr/local/bin/yq

            - name: Process Request
              run: |
                  PATH_PARAMS="${{ github.event.client_payload.path }}"
                  ACTION="${{ github.event.client_payload.action }}"
                  SPEC='${{ toJson(github.event.client_payload.spec) }}'

                  IFS='/' read -r -a PATH_ARRAY <<< "$PATH_PARAMS"
                  PROJECT="${PATH_ARRAY[1]}"
                  SUB_RESOURCE="${PATH_ARRAY[2]}"
                  NAME="${PATH_ARRAY[3]}"

                  FILE="projects/$PROJECT.yaml"

                  if [ "$ACTION" = "apply" ] && [ -f "$FILE" ]; then
                    if [ "$SUB_RESOURCE" = "applications" ]; then
                      yq eval "(.applications[] | select(.name == \"$NAME\")) *= ${SPEC}" -i $FILE
                    elif [ "$SUB_RESOURCE" = "databases" ]; then
                      yq eval "(.databases[] | select(.name == \"$NAME\")) *= ${SPEC}" -i $FILE
                    fi
                  fi

            - name: Commit and push changes
              run: |
                  git config user.name "CI Bot"
                  git config user.email "[email protected]"
                  git add .
                  git commit -m "${{ github.event.client_payload.action }} ${{ github.event.client_payload.path }}"
                  git push

This workflow receives repository_dispatch events and uses yq to update project configuration files. When a CI build succeeds, it updates the git.hash field to the new commit hash. ArgoCD then detects the change and deploys the new image.

Project Creation and Usage

In practice, adding a new project to this platform looked like this.

Creating a Project Configuration File

The projects/myproject.yaml file in this setup looked like this:

applications:
    - name: api
      git:
          type: github
          owner: myorg
          repo: my-api-server
          branch: main
      port: 8080
      domains:
          - api.myproject.example.com

databases:
    - name: mysql
      type: mysql
      version: "8.0"
      port: 3306
      size: 2Gi

Storing Secrets in Vault

I first stored the project-specific secrets in Vault:

vault kv put injunweb/myproject-github-access username=myuser token=ghp_xxxxx
vault kv put injunweb/myproject-mysql-secret password=mysecretpassword
vault kv put injunweb/myproject-api-secret API_KEY=my-api-key

Verifying Deployment

After I committed and pushed the project configuration file, ArgoCD created the related resources automatically:

kubectl get ns myproject
kubectl get eventbus,eventsource,sensor -n myproject
kubectl get statefulset -n myproject

When code is pushed to the GitHub repository, the CI pipeline runs automatically, then builds and deploys the application.

Conclusion

This post covered how I built a small Internal Developer Platform (IDP) on top of the homelab Kubernetes cluster using Helm templates and ArgoCD ApplicationSet. The biggest payoff was not having to rebuild the same CI/CD and infrastructure plumbing every time I wanted to stand up a new project.

The next post covers installing Prometheus, Grafana, and Loki to build a monitoring setup that collects and visualizes cluster metrics and logs.

Next Post: Homelab Build Log #9: Prometheus Monitoring