Skip to main content

Deploying an application with ArgoCD

This guide walks through deploying an application end to end using the Container Platform GitOps workflow. You describe your workload in Git, open a pull request, and once it merges ArgoCD deploys it to the target cluster for you. There is no kubectl apply and no manual ArgoCD step: the merge is the deploy.

It ties together two earlier guides, Adding a New Product and Adding a service deployment, into a single worked example you can follow start to finish. It deploys a public container image to one non-live environment, which is the smallest complete path that proves the workflow.

How GitOps deployment works

Your workloads live in the container-platform-environments repository. ArgoCD watches that repository and keeps each cluster matching what is committed. Two processes read your files:

  • The baseline process reads your product.yaml and creates the namespace, a default-deny network policy, and the role bindings that grant your team access.
  • The workload process reads each environment values file and deploys your service’s Helm chart into the mapped namespace.

Because Git is the source of truth, deploying, updating, and rolling back are all ordinary pull requests. Non-live environments sync automatically once a change merges.

Before you start

You need:

  • Write access to the container-platform-environments repository, where all workloads are declared.
  • A target business unit and non-live cluster. This guide uses octo and container-platform-octo-nonlive.
  • A container image from a public registry. This guide uses nginxinc/nginx-unprivileged, which listens on port 8080 and needs no configuration.
  • helm installed locally, to validate your chart before you open a pull request.

You do not need cluster credentials to deploy. They are useful for verifying the result afterwards. See Accessing the Platform for how to get kubectl access.

What you will create

A product follows a fixed layout so the platform can find it. This guide creates a product called my-app with a single service, also called my-app, under the octo business unit:

namespaces/octo/my-app/
├── product.yaml                         # declares environments + access
└── my-app/
    └── deployment/                      # the service Helm chart
        ├── Chart.yaml
        ├── values.yaml                  # defaults (image, resources, service)
        ├── templates/
        │   ├── deployment.yaml
        │   ├── service.yaml
        │   └── httproute.yaml           # routes the hostname to the Service
        └── values/
            └── nonlive/
                └── dev.yaml             # per-environment overrides + routing

Step 1: Declare the product

Create namespaces/octo/my-app/product.yaml. This declares one non-live environment and grants your GitHub team edit on the non-live cluster.

product: my-app
bu: octo
owner:
  team: my-team
  slack: "#my-team-channel"
  source: https://github.com/ministryofjustice/container-platform-environments
environments:
  - name: dev
    cluster: container-platform-octo-nonlive
    namespace: my-app-dev
    is_production: false
access:
  - group: my-team
    role: edit
    clusters:
      - container-platform-octo-nonlive

The namespace value is the namespace created on the cluster. The group value is your GitHub team, which the platform maps to cluster access. is_production: false marks this as non-live; live environments have extra requirements covered in a later guide. See Adding a New Product for the full reference.

Step 2: Create the service Helm chart

Create namespaces/octo/my-app/my-app/deployment/Chart.yaml:

apiVersion: v2
name: my-app
description: my-app service
type: application
version: 1.0.0
appVersion: "1.0.0"

Create deployment/values.yaml with the defaults. Pin the image by tag and digest so a deploy is reproducible and a moving tag cannot change what runs:

# Default values for my-app. Overridden per environment by values/<tier>/<env>.yaml
replicaCount: 1

image:
  repository: nginxinc/nginx-unprivileged
  tag: "1.27.4"
  digest: "sha256:<pin-the-digest-you-tested>"

resources:
  requests:
    cpu: 50m
    memory: 64Mi
  limits:
    cpu: 100m
    memory: 128Mi

service:
  port: 80
  targetPort: 8080

Create the templates. deployment/templates/deployment.yaml runs the container with the security context that platform namespaces enforce (non-root, no privilege escalation, all capabilities dropped):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  labels:
    app: my-app
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      securityContext:
        runAsNonRoot: true
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: my-app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}@{{ .Values.image.digest }}"
          ports:
            - containerPort: {{ .Values.service.targetPort }}
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
          resources:
            requests:
              cpu: {{ .Values.resources.requests.cpu }}
              memory: {{ .Values.resources.requests.memory }}
            limits:
              cpu: {{ .Values.resources.limits.cpu }}
              memory: {{ .Values.resources.limits.memory }}

deployment/templates/service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: my-app
  labels:
    app: my-app
spec:
  selector:
    app: my-app
  ports:
    - port: {{ .Values.service.port }}
      targetPort: {{ .Values.service.targetPort }}
      protocol: TCP

To reach the app over HTTPS, add an HTTPRoute that attaches to the shared platform listener, default-listenerset in the envoy-gateway-system namespace. That listener already terminates TLS for any hostname under the cluster wildcard *.<business-unit>.container-platform.service.justice.gov.uk, so a route is all you need. deployment/templates/httproute.yaml:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: my-app
spec:
  parentRefs:
    - name: default-listenerset
      namespace: envoy-gateway-system
      kind: ListenerSet
      group: gateway.networking.k8s.io
      sectionName: https
  hostnames:
    - {{ required "host must be configured" .Values.host | quote }}
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: my-app
          port: {{ .Values.service.port }}

Because your hostname (my-app.octo-nonlive.container-platform.service.justice.gov.uk) falls under that wildcard, you do not need to define your own listener or manage a certificate. If you only need to prove the deploy and do not need external access yet, you can omit the HTTPRoute and verify the Deployment directly. For a fuller walkthrough of routing, see Expose a service with Gateway API (HTTPRoute).

Using a custom domain

If your hostname is not under the cluster wildcard (for example a public domain such as www.my-service.gov.uk), the shared listener cannot serve it: the wildcard will not match, and its certificate does not cover your domain. In that case, define your own ListenerSet with its own certificate and point the HTTPRoute at it instead of default-listenerset.

deployment/templates/listenerset.yaml (custom-domain case only):

apiVersion: gateway.networking.k8s.io/v1
kind: ListenerSet
metadata:
  name: my-app
  annotations:
    cert-manager.io/cluster-issuer: {{ .Values.tls.clusterIssuer | quote }}
spec:
  parentRef:
    name: {{ .Values.gateway.name }}
    namespace: {{ .Values.gateway.namespace }}
    group: gateway.networking.k8s.io
    kind: Gateway
  listeners:
    - name: https
      hostname: {{ required "host must be configured" .Values.host | quote }}
      protocol: HTTPS
      port: 443
      tls:
        mode: Terminate
        certificateRefs:
          - name: my-app-tls
            kind: Secret
      allowedRoutes:
        namespaces:
          from: Same

When you use a custom ListenerSet, point the HTTPRoute parentRefs at your own listener (name: my-app) rather than default-listenerset, and add the gateway and tls keys back to your values files. cert-manager then issues a certificate for your domain, so you must also have the DNS for that domain pointing at the platform.

Step 3: Add the environment values file

Create deployment/values/nonlive/dev.yaml. Its location in the path sets the tier (nonlive) and environment (dev), and the platform keys on this file to create your Application:

namespace: my-app-dev
replicaCount: 1
host: my-app.octo-nonlive.container-platform.service.justice.gov.uk

The namespace value is required and must match the namespace you declared in product.yaml (my-app-dev). It tells the platform which namespace to deploy into. The host value is the hostname your HTTPRoute serves. Any other keys override the chart defaults for this environment only.

Your Application is named <bu>-<service>-<env>, so this file produces one called octo-my-app-dev. Note that name; you will look for it when you verify.

Step 4: Validate the chart locally

Render the chart with the environment values before you open a pull request. This catches template errors and missing required values (such as host) without waiting on ArgoCD:

cd namespaces/octo/my-app/my-app/deployment

# Lint the chart
helm lint . --values values.yaml --values values/nonlive/dev.yaml

# Render the manifests exactly as the platform will
helm template my-app . \
  --values values.yaml \
  --values values/nonlive/dev.yaml

Check that the Deployment references your pinned image, the Service targets port 8080, and the hostname is filled in on the HTTPRoute. If helm template fails with host must be configured, your dev.yaml is missing the host key.

Step 5: Open a pull request and merge

Commit the new files on a branch, push, and open a pull request against main in container-platform-environments:

git checkout -b deploy-my-app
git add namespaces/octo/my-app
git commit -m "Add my-app product and non-live deployment"
git push -u origin deploy-my-app

Once the pull request is reviewed and merged to main, the platform picks up the change automatically. Non-live workloads sync on their own (with prune and self-heal), so no further action is needed after the merge.

Step 6: Verify the deployment

The namespace is created first, then the workload deploys into it. If you check immediately, the workload may briefly show as degraded until the namespace exists. This is expected and clears on its own.

Point kubectl at the non-live cluster (see Accessing the Platform), then check your namespace:

kubectl -n my-app-dev get deploy,pods,svc
kubectl -n my-app-dev get httproute

The pod should be Running, and the HTTPRoute should show an Accepted condition of True (check with kubectl -n my-app-dev describe httproute my-app). Once DNS has propagated, the app answers at https://my-app.octo-nonlive.container-platform.service.justice.gov.uk.

Watch the deployment in the ArgoCD UI

You can also follow the deployment in the ArgoCD web UI. Access is through the AWS access portal using your Container Platform SSO role.

  1. Sign in to the AWS access portal and open the Applications tab.
  2. Search for the environment tier you are deploying to (for example nonlive) and open the matching ArgoCD capability, for example EKS Managed ArgoCD Capability-argocd-cloud-platform-nonlive. Non-live workloads are managed by the non-live hub; live workloads by the live hub.

The AWS access portal Applications tab, filtered to "nonlive", showing the EKS Managed ArgoCD Capability for cloud-platform-nonlive

  1. ArgoCD opens showing the Applications view. Search for your product name to filter to your Applications. Application (tenant) engineers get read-only access, which is enough to watch the sync.

The ArgoCD Applications view listing Applications for a product, each showing its project, sync status and health status

Example shown: the helloworld product. Your Applications will carry your own product and service names.

Look for two items to reach Synced / Healthy:

  • baseline-octo-my-app-dev — creates the namespace, network policy, and role bindings.
  • octo-my-app-dev — your workload.

Each tile shows the Sync Status (Synced or OutOfSync) and Health Status (Healthy, Progressing, Degraded, and so on), along with the source repository, target revision, and destination cluster and namespace.

Update your application

To deploy a new version, change the image and merge. Update image.tag and image.digest in values.yaml (or override them in dev.yaml for this environment only), open a pull request, and merge. The non-live workload syncs automatically, so the new image rolls out within a sync cycle. Watch octo-my-app-dev return to Synced / Healthy on the new version.

Roll back

Because Git is the source of truth, a rollback is a Git revert. Revert the commit that introduced the change and merge:

git revert <commit-sha>
git push

Self-heal reconciles the cluster back to the reverted state. Avoid rolling back by editing the cluster directly: the platform detects the drift and syncs it back to whatever is in Git.

Troubleshooting

The workload is stuck “Missing” or degraded on the namespace

The namespace has not been created yet. Confirm your product.yaml is valid and sits at namespaces/octo/my-app/product.yaml. The workload cannot create its own namespace; it waits for the baseline to create it first.

A deploy fails with a “forbidden” error

Your chart is trying to create a resource type that workloads are not allowed to create, such as a cluster-scoped resource. Workloads are limited to namespace-scoped resources (Deployments, Services, ConfigMaps, Secrets, routes, and similar). Remove the cluster-scoped resource from your chart.

The pod is stuck in ImagePullBackOff

The image reference is wrong or unreachable. Check the repository, tag, and digest in values.yaml, and that the digest matches the tag. A public image needs no credentials; a private image must live somewhere the cluster can pull from.

Changes merged but nothing happened

Check the values file path is exactly namespaces/octo/my-app/my-app/deployment/values/nonlive/dev.yaml. An extra or missing directory level means no Application is created. Confirm the file reached main.

Next steps

A later guide will cover the full development-to-live lifecycle using the platform test application: publishing the image, promoting a workload from dev through staging to prod, and the manual approval step that live deployments require.

This page was last reviewed on 20 August 2026. It needs to be reviewed again on 20 February 2027 by the page owner #cloud-platform-notify .