# Designing a Reliable Kubernetes Deployment with GitOps

Deploying an application to a Kubernetes cluster is not just about building a container image and running it. A reliable deployment workflow must define how artifacts are built, where they are stored, which version is selected for release, and how the desired configuration is applied to the cluster.

As these responsibilities grow, maintaining a clear relationship between application code, image versions, and deployment manifests becomes increasingly important.

> Without an explicit workflow, deployments can become difficult to trace, inconsistent, and teams may lose visibility into which application version is actually running.

The problem becomes clearer when these responsibilities are not explicitly connected:

![](https://cdn.hashnode.com/uploads/covers/6a72f2b0d4e00a735e16aa74/01237cb8-276f-4f14-897a-1da9cd4bfa34.png align="center")

These challenges motivate a workflow that makes image versions, deployment configuration, and release decisions explicit.

This is where Continuous Integration (CI) and GitOps complement each other: CI automates building and publishing container images, while GitOps manages the desired deployment state through version-controlled configuration.

> The code snippets in this article focus on the important parts of the implementation. For the complete configuration you can find the source code in the repository below.
> 
> [Application Repository](https://github.com/muhammadyulasfipahrizal/E2E-observability-stack.git)
> 
> [GItops Repository](https://github.com/muhammadyulasfipahrizal/gitops.git)

## What We Are Building

In this project, we build a deployment that establish a workflow in which application artifacts are versioned, deployment changes are traceable, and Kubernetes continuously converges toward an explicitly defined desired state.

![](https://cdn.hashnode.com/uploads/covers/6a72f2b0d4e00a735e16aa74/897ec9da-bcdf-4fd8-adf7-5727272623a9.png align="center")

This project uses two repositories with separate responsibilities:

*   **Application repository:** Go API source code, Dockerfile, and CircleCI configuration.
    
*   **GitOps repository:** Helm chart and production values that declare the desired Kubernetes configuration.
    

The supporting components are:

*   **CircleCI** builds the application image and pushes it to Harbor.
    
*   **Harbor** stores versioned container images.
    
*   **Helm** templates the Kubernetes resources from chart and values.
    
*   **Argo CD** compares the GitOps configuration with the cluster and synchronizes it.
    
*   **Kubernetes** schedules and runs the workload.
    

The pipeline automates image delivery, while release promotion remains explicit: a person updates the GitOps image tag to deliberately select the version to deploy, then manually starts synchronization in Argo CD to control when that version is applied to the cluster.

Automatically deploying every published image is not always desirable because a successful build does not necessarily mean the image is ready for release. New builds may still require validation, review, or coordination with the target environment.

Separating image publishing from deployment provides clear release checkpoints and reduces the risk of unreviewed changes reaching users.

## Project Architecture

![](https://cdn.hashnode.com/uploads/covers/6a72f2b0d4e00a735e16aa74/2afe1d95-19b1-4690-84cb-b9fbd1d22c07.png align="center")

### Artifact delivery and deployment

The CI path produces an image. The deployment path declares which image should run and applies that desired state to Kubernetes. The GitOps repository is the handoff between them.

The dashed connection is not an automated pipeline step. CircleCI does **not** commit to the GitOps repository. After an image is published, the image tag is updated manually in `environments/production/values-prod.yaml`. Argo CD then shows the configuration difference; a user manually selects Sync to apply it.

### Application repository

Repository: [https://github.com/muhammadyulasfipahrizal/circle](https://github.com/muhammadyulasfipahrizal/circle)

![](https://cdn.hashnode.com/uploads/covers/6a72f2b0d4e00a735e16aa74/dd43dfff-87c8-423d-b47b-1003fda11cde.png align="center")

| File | Responsibility |
| --- | --- |
| `app/main.go` | Implements the Go HTTP API, including `/health`. |
| `app/go.mod` | Defines the Go module and dependencies. |
| `Dockerfile` | Builds the binary and packages it into a minimal runtime image. |
| `.circleci/config.yml` | Defines the CircleCI build-and-push job. |

### GitOps repository

Repository: [https://github.com/muhammadyulasfipahrizal/gitops](https://github.com/muhammadyulasfipahrizal/gitops)

![](https://cdn.hashnode.com/uploads/covers/6a72f2b0d4e00a735e16aa74/f5cea93d-6a95-49b3-b6f7-4b1b2cf43d49.png align="center")

| File | Responsibility |
| --- | --- |
| `charts/app/Chart.yaml` | Defines Helm chart metadata. |
| `charts/app/values.yaml` | Provides default chart values. |
| `charts/app/templates/_helpers.tpl` | Defines reusable template helpers. |
| `charts/app/templates/deployment.yaml` | Defines the application Deployment. |
| `charts/app/templates/service.yaml` | Defines the Service. |
| `charts/app/templates/ingress.yaml` | Defines the Ingress resource. |
| `environments/production/values-prod.yaml` | Supplies production-specific overrides, including image tag, replica count, service port, and resources. |

The chart defines reusable resource templates; the production values supply the settings for this deployment. The separation makes it possible to review a release by examining a small Git diff instead of changing Kubernetes resources directly.

## Implementation

The Go application exposes a `/health` endpoint so that we can check whether it responds after deployment.

The Dockerfile uses a multi-stage build: the first stage compiles the Go application, and the final image contains the binary rather than the Go toolchain.

```dockerfile
FROM golang:1.22-alpine AS builder

WORKDIR /src

COPY app/go.mod ./
RUN go mod download

COPY app/ .

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -o /app .

FROM gcr.io/distroless/static-debian12:nonroot

WORKDIR /
COPY --from=builder /app /app

EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app"]
```

This keeps build tools out of the runtime image and runs the application as a non-root user. The application and Kubernetes Service are configured to use port `8080`.

We can test the container locally before involving the cluster:

```bash
docker build -t pahri-k8s:local .
docker run --rm -p 8080:8080 pahri-k8s:local
```

In another terminal:

```bash
curl http://localhost:8080/health
```

Expected response:

```json
{"status":"ok"}
```

### Automate image delivery with CircleCI

The CircleCI configuration lives in `.circleci/config.yml`. The configured job uses the custom runner resource class `pahri/devops` and an Ubuntu machine executor. It checks out the application repository, authenticates with Harbor, builds the image, and pushes it.

Registry credentials are supplied through environment variables rather than committed to Git:

```yaml
- run:
    name: Login Harbor
    command: |
      echo "$HARBOR_PASSWORD" | docker login \
        "$HARBOR_REGISTRY" \
        -u "$HARBOR_USERNAME" \
        --password-stdin
```

The image is tagged with CircleCI's `$CIRCLE_SHA1`, linking the published artifact to the source commit:

```yaml
- run:
    name: Build Image
    command: |
      IMAGE="$HARBOR_REGISTRY/production/pahri-k8s"
      docker build -t "$IMAGE:$CIRCLE_SHA1" .

- run:
    name: Push Image
    command: |
      IMAGE="$HARBOR_REGISTRY/production/pahri-k8s"
      docker push "$IMAGE:$CIRCLE_SHA1"
```

The registry address and credentials must be configured in the CircleCI environment. The snippets above show the relevant commands; the complete job also needs its normal CircleCI job and workflow structure.

The image name used by this project is:

```text
harbor.k8s.local/production/pahri-k8s:<commit-sha>
```

![](https://cdn.hashnode.com/uploads/covers/6a72f2b0d4e00a735e16aa74/7b32d005-9b53-4b90-96d6-c9206b29bdd2.png align="center")

### Confirm the artifact in Harbor

Harbor is the private registry for the image that the Kubernetes workload will pull. After CircleCI finishes successfully, confirm that the repository contains the new commit-SHA tag.

![](https://cdn.hashnode.com/uploads/covers/6a72f2b0d4e00a735e16aa74/c96072e1-a790-4b15-80d6-6fc2e72ce684.png align="center")

At this stage, the artifact exists in the registry, but the running deployment has not necessarily changed. Kubernetes uses the image reference declared in the GitOps configuration, not simply the most recently pushed image.

### Define the workload with Helm

The chart under `charts/app/` defines the Kubernetes resources. The production values file declares the desired image, replica count, service port, and resource requests and limits.

```yaml
replicaCount: 3

image:
  repository: harbor.k8s.local/production/pahri-k8s
  tag: <tag>

service:
  port: 8080

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi
```

The replica count expresses the desired number of application replicas. Requests inform scheduling, while limits define the configured CPU and memory ceilings for each container. The Service provides a stable in-cluster endpoint, and the Ingress template defines external HTTP routing when that resource is enabled and configured.

Before synchronizing a change, inspect the rendered manifests from the root of the `GitOps` repository:

```bash
helm template pahri-k8s ./charts/app \
  -f environments/production/values-prod.yaml
```

Review the rendered image reference, replica count, Service, and resource settings. Rendering the chart lets us inspect the intended resources without applying them to the cluster.

![](https://cdn.hashnode.com/uploads/covers/6a72f2b0d4e00a735e16aa74/ba9f4e1e-e9b6-44e0-a0c4-39f164ec634a.png align="center")

### Promote an image by updating the GitOps repository

This step is intentionally manual. CircleCI publishes the image, but it does not edit or commit to the GitOps repository. The operator selects the image version to promote by updating:

```text
environments/production/values-prod.yaml
```

For example:

```yaml
image:
  repository: harbor.k8s.local/production/pahri-k8s
  tag: <new-tag>
```

Use the exact tag that CircleCI pushed successfully. Then commit and push the change:

```bash
git add environments/production/values-prod.yaml
git commit -m "Update production application image"
git push
```

The Git diff records which version is being promoted and preserves that change in the repository history. This is a deliberate control point: a successful image build does not automatically mean that the new image should be deployed.

### Synchronize in Argo CD

Argo CD reads the GitOps repository and compares the desired configuration with the live resources. In this project, synchronization is initiated manually rather than automatically.

After pushing the updated values file:

1.  Open the application in Argo CD.
    
2.  Review the application status and the pending differences.
    
3.  Confirm that the intended image tag is shown in the desired configuration.
    
4.  Select **Sync** to apply the desired state.
    
5.  Wait for the workload rollout and check the application health.
    

![](https://cdn.hashnode.com/uploads/covers/6a72f2b0d4e00a735e16aa74/ea540dd4-34b9-40d0-8d2e-452a81e555d8.png align="center")

This manual Sync action is separate from the manual Git edit. The Git commit changes the desired state; clicking Sync requests that Argo CD apply it. Keeping both actions explicit provides a clear release decision and avoids suggesting that image publication alone deploys a new version.

![](https://cdn.hashnode.com/uploads/covers/6a72f2b0d4e00a735e16aa74/3edde6be-6b0d-43f5-bf78-0dccbc5a7cda.png align="center")

A `Synced` status indicates that the tracked resources match the desired state Argo CD is using. Health status provides additional information about managed resources, but neither one alone proves that an application endpoint responds correctly.

### Verify the deployed release

Verify the deployment at three levels: Kubernetes workload readiness, the image reference, and the application endpoint.

Check the Deployment, Pods, and Service in the namespace:

```bash
kubectl get deployment,pods,svc -n pahri-k8s
```

The Deployment should show the expected number of available replicas, and the Pods should become ready. If they do not, inspect the Pod and recent events:

```bash
kubectl describe pod <pod-name> -n pahri-k8s

kubectl get events -n pahri-k8s \
  --sort-by=.metadata.creationTimestamp
```

Check the image reference on the Deployment:

```bash
kubectl get deployment -n pahri-k8s \
  -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.containers[*].image}{"\n"}{end}'
```

The image should match the commit-SHA tag in `values-prod.yaml`.

If the application does not have a reachable Ingress or external endpoint, use port forwarding to test it through the Kubernetes Service:

```bash
kubectl port-forward -n pahri-k8s \
  svc/pahri-k8s-app 8080:8080
```

In another terminal:

```bash
curl http://IP:8080/health
```

Expected response:

```json
{"status":"ok"}
```

If the application is exposed through Ingress, test that route separately as well. Port-forwarding validates the Service-to-application path, not the full external access path.

![](https://cdn.hashnode.com/uploads/covers/6a72f2b0d4e00a735e16aa74/00b54348-6011-4d28-9121-8fb2f9c87258.png align="center")

The evidence should tell one consistent story: CircleCI published a specific image tag, the GitOps values file declares that tag, the operator manually synchronized it through Argo CD, and Kubernetes is running the declared image while the application responds successfully.

## Conclusion

This project demonstrates how Continuous Integration and GitOps can work together to create a deployment workflow that is traceable, predictable, and controlled.

The key design decision is to separate artifact delivery from release promotion. Publishing an image does not automatically make it a production release. Instead, an operator explicitly selects the image version in Git and initiates synchronization through Argo CD. These two control points make release decisions visible, preserve configuration history, and reduce the risk of deploying unreviewed changes.

> This approach prioritizes release control and traceability over fully automated deployment.

Ultimately, a reliable deployment workflow is not defined by how many steps it automates, but by how clearly it connects artifacts, configuration, and release decisions. By making these relationships explicit, this project provides a practical foundation for managing Kubernetes deployments with greater confidence and accountability.
