Kubernetes has become the standard for container orchestration, but wiring it into a deployment workflow trips people up in a specific place — and it usually isn't kubectl. It's getting two things to the right place at the right time: the container image, and the manifests that reference it. This guide covers deploying to Kubernetes with [DeployHQ](https://www.deployhq.com), and is explicit about which [DeployHQ](https://www.deployhq.com) mechanism delivers what, because that's where most setups stall.

## Understanding the DeployHQ + Kubernetes architecture

[DeployHQ](https://www.deployhq.com) doesn't deploy directly to Kubernetes pods. It executes `kubectl` commands against your cluster, and you choose where those commands run. Before the first line of config, three constraints are worth knowing — each one is a setup that fails in a confusing way if you assume otherwise:

- **The build pipeline cannot build container images.** Its sandboxed environment provides language runtimes (Node, PHP, Python, Ruby, Go, Java, .NET); Docker is not among the supported tools. `docker build` in a build pipeline is not a workflow that runs.
- **Shell Server deployments do not upload your repository.** Only config files are transferred. A `kubectl apply -k k8s/overlays/production/` on a Shell Server has nothing to apply unless that host fetched the repo itself.
- **Custom Action deployments _do_ mount your repository** , at `/data`. If your manifests live in Git — and they should — this is the difference that matters.

So the image gets built in your CI, and the manifests reach the cluster by whichever protocol you pick. Here's the shape:

```
flowchart LR
    A[Git push] --> B[CI: builds image, pushes to registry]
    B --> C[Trigger DeployHQ]
    C --> D{Deployment protocol}
    D -->|Custom Action| E[Repo mounted at /data]
    D -->|Shell Server| F[Host git-pulls the repo]
    E --> G[kubectl apply -k]
    F --> G
    G --> H[Kubernetes cluster]
```

We cover the AWS-specific version of this — ECS task definitions, EKS, and ECR's particular quirks — in [deploying to AWS ECS and EKS with](https://www.deployhq.com/blog/deploying-applications-to-aws-ecs-eks-using-deployhq-shell-servers)[DeployHQ](https://www.deployhq.com) Custom Actions. This guide stays cloud-agnostic and goes deeper on Kustomize, RBAC, and probes.

## Prerequisites

1. **A Kubernetes cluster** (EKS, GKE, AKS, or self-hosted)
2. **A CI system that can run `docker build`** — GitHub Actions, GitLab CI, CodeBuild, or similar
3. **A container registry** your cluster can pull from, and that your CI can push to
4. **Cluster credentials** , scoped to a deployment service account rather than cluster-admin
5. **`kubectl`** available wherever your deployment commands run, matching your cluster's minor version
6. **The standalone `kustomize` binary** on that same host. This one catches people out: kubectl embeds enough Kustomize to _build_ (`kubectl apply -k`), but not to _edit_ — `kustomize edit set image` needs the separate binary

> **New to [DeployHQ](https://www.deployhq.com)?** [Start a free trial](https://www.deployhq.com/signup) and create a project for your repository before working through the steps below — you'll need it for the server and deployment commands.

## Choosing your deployment protocol

| | Custom Action | Shell Server |
| --- | --- | --- |
| Repository available to commands | Yes, mounted at `/data` | No — fetch it yourself |
| Infrastructure you maintain | None | The host |
| Reaches a private/VPC-only control plane | No | Yes, if it sits inside the network |
| State persists between deploys | No, fresh container each time | Yes |

**Use a Custom Action** if your cluster's API is publicly reachable. There's nothing to patch and your manifests are simply there.

**Use a Shell Server** when the control plane is only reachable from inside a VPC or private network, or when you need a host that holds state between runs. The trade-off is that you own the machine — patching, hardening, monitoring, credential rotation.

The rest of this guide uses Shell Server examples, since that path has the extra step people miss. Everything translates to a Custom Action by dropping the `git pull` and pointing paths at `/data`.

## Step 1: Build and push the image in CI

Tag with the commit SHA. If you only push `:latest`, you've thrown away your rollback target and turned a 30-second incident into a 30-minute one.

```
- name: Build and push
  run: |
    IMAGE=myregistry/myapp:${{ github.sha }}
    docker build -t "$IMAGE" .
    docker push "$IMAGE"
```

Note what's deliberately absent: no `docker tag ... :latest` and no second push. A mutable `latest` tag alongside an immutable SHA tag invites a rollback that silently lands on the wrong image. Keep one tag per build.

## Step 2: Trigger DeployHQ from CI

Once the image exists, hand off to [DeployHQ](https://www.deployhq.com) for the release. [DeployHQ](https://www.deployhq.com) publishes a GitHub Action that blocks until the deployment reaches a terminal status, so a failed rollout fails your pipeline instead of passing silently:

```
- name: Deploy via DeployHQ
  uses: deployhq/deploy-action@<pinned-commit-sha>
  with:
    account: your-account
    project: myapp
    server: production-k8s
    revision: ${{ github.sha }}
    api-key: ${{ secrets.DEPLOYHQ_API_KEY }}
```

Pin to an immutable commit SHA rather than a floating tag — a moving tag is a supply-chain path straight to your deployment credentials. On GitLab CI or CodeBuild, call the [DeployHQ](https://www.deployhq.com) deployments API directly; the handoff is the same idea. Our writeup on [how we deploy](https://www.deployhq.com/blog/how-we-deploy-deployhq-github-actions-docker)[DeployHQ](https://www.deployhq.com).com with GitHub Actions, Docker, and the [DeployHQ](https://www.deployhq.com) Action covers the reasoning, including the staleness guard that stops an out-of-order build rolling an environment backwards.

Keeping the build in CI and the release in [DeployHQ](https://www.deployhq.com) isn't a workaround for a limitation — it means rollback never rebuilds, and your [deployment history and one-click rollback](https://www.deployhq.com/features/one-click-rollback) look identical across Kubernetes, VPS, and static projects in the same account.

## Step 3: Set up the Shell Server

### Installing kubectl

```
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
kubectl version --client
```

### Giving the host your repository

This is the step that's easy to miss. Because a Shell Server deployment doesn't upload your repository, clone it once during setup:

```
sudo mkdir -p /opt/deploy && sudo chown "$USER" /opt/deploy
git clone git@github.com:yourorg/myapp.git /opt/deploy/myapp
```

Your deployment commands will `git fetch` from here on each run. Use a read-only deploy key — this host needs to read manifests, never to write to the repository.

### Configuring cluster access

```
mkdir -p ~/.kube
aws eks update-kubeconfig --region us-east-1 --name my-cluster # or your provider's equivalent
kubectl cluster-info
```

For production, bind to a service account with only the permissions a deployer needs:

```
# k8s/rbac/deployer-service-account.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: deployhq-deployer
  namespace: production
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: deployhq-deployer-role
  namespace: production
rules:
  - apiGroups: ["apps"]
    resources: ["deployments", "replicasets"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
  - apiGroups: [""]
    resources: ["pods", "services", "configmaps"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: deployhq-deployer-binding
  namespace: production
subjects:
  - kind: ServiceAccount
    name: deployhq-deployer
    namespace: production
roleRef:
  kind: Role
  name: deployhq-deployer-role
  apiGroup: rbac.authorization.k8s.io
```

Note there's no `delete` verb and no access to `secrets`. A deployer that can roll out a new ReplicaSet doesn't need to read your database credentials or remove resources; scoping it this way means a compromised deploy key can't exfiltrate secrets. Namespace the Role rather than using a ClusterRole unless you genuinely deploy across namespaces.

## Manifest structure

```
myapp/
├── k8s/
│ ├── base/
│ │ ├── deployment.yaml
│ │ ├── service.yaml
│ │ └── configmap.yaml
│ └── overlays/
│ ├── staging/
│ │ └── kustomization.yaml
│ └── production/
│ └── kustomization.yaml
├── src/
└── Dockerfile
```

### Base deployment manifest

```
# k8s/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  labels:
    app: myapp
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  progressDeadlineSeconds: 300
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: myregistry/myapp:placeholder
          ports:
            - containerPort: 8080
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
            limits:
              memory: "256Mi"
              cpu: "500m"
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 20
            failureThreshold: 3
          startupProbe:
            httpGet:
              path: /health/startup
              port: 8080
            periodSeconds: 5
            failureThreshold: 30
          envFrom:
            - configMapRef:
                name: myapp-config
            - secretRef:
                name: myapp-secrets
```

Three details that earn their place.

`maxUnavailable: 0` with `maxSurge: 1` is what actually delivers [zero-downtime deployments](https://www.deployhq.com/features/zero-downtime-deployments) — Kubernetes brings a new pod to Ready before retiring an old one, so capacity never dips below your replica count.

`progressDeadlineSeconds: 300` is the fix for the most common Kubernetes deployment hang. Without it, a pod that starts but never passes its readiness probe leaves `kubectl rollout status` blocking until its timeout with no useful signal. With it, the rollout is marked failed after five minutes and your deployment fails fast and loudly.

The image is `:placeholder` deliberately. Kustomize overwrites it per deployment (below), so the manifest in Git never carries a stale tag that could be applied by accident.

### Service and ConfigMap

```
# k8s/base/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: myapp
spec:
  selector:
    app: myapp
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
  type: ClusterIP
```

```
# k8s/base/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: myapp-config
data:
  LOG_LEVEL: "info"
  API_TIMEOUT: "30s"
```

## Kustomize for environment management

Kustomize ships inside kubectl and handles per-environment differences without duplicating manifests.

```
# k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
  - service.yaml
  - configmap.yaml
commonLabels:
  app.kubernetes.io/name: myapp
  app.kubernetes.io/managed-by: deployhq
```

```
# k8s/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
  - ../../base
replicas:
  - name: myapp
    count: 5
configMapGenerator:
  - name: myapp-config
    behavior: merge
    literals:
      - LOG_LEVEL=warn
```

`configMapGenerator` appends a content hash to the ConfigMap name and rewrites the reference in the Deployment. That means a config-only change produces a new pod template and therefore a real rolling restart — where a hand-written ConfigMap edit would update the object and leave every running pod on the old values until something happened to restart them. It's a quietly common source of I changed the config and nothing happened.

Staging is the same file with `namespace: staging`, a lower replica count, and `LOG_LEVEL=debug`. For the broader pattern, see [managing multiple environments with DeployHQ](https://www.deployhq.com/blog/managing-multiple-environments-with-deployhq-dev-staging-and-production).

## Secrets

Never commit secrets. Generate them from variables held in [DeployHQ](https://www.deployhq.com), or better, let the cluster pull them from a secret manager.

```
kubectl create secret generic myapp-secrets \
  --from-literal=DATABASE_URL="$DATABASE_URL" \
  --dry-run=client -o yaml | kubectl apply -f -
```

The `--dry-run=client -o yaml | kubectl apply -f -` idiom exists because `kubectl create secret` fails if the secret already exists; this makes the operation idempotent, which matters when every deployment runs it.

For production, External Secrets Operator keeps credentials out of the deployment path entirely:

```
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: myapp-secrets
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: myapp-secrets
  data:
    - secretKey: DATABASE_URL
      remoteRef:
        key: myapp/production
        property: database_url
```

That's why the RBAC Role above grants no access to `secrets` — with this operator, the deployer never needs to touch them. See [how to keep API keys out of your Git repository](https://www.deployhq.com/blog/protecting-your-api-keys-a-quick-guide) for the wider practice.

## Step 4: The deployment script

Commit this to the repo as `deploy/k8s-deploy.sh`. Keeping it in Git means it's reviewable in a pull request and testable locally, rather than living in a textarea.

```
#!/bin/bash
set -euo pipefail

REPO_DIR=/opt/deploy/myapp
NAMESPACE="${DEPLOY_NAMESPACE:-production}"
TIMEOUT="${DEPLOY_TIMEOUT:-300s}"

# The commit being deployed, supplied by DeployHQ. Fail loudly if unset rather
# than deploying a tag that was never built.
REVISION="${DEPLOYHQ_REVISION:?revision not set - see the note below}"

cd "$REPO_DIR"
git fetch --quiet origin
git checkout --quiet --force "$REVISION"

echo "=== Deploying $REVISION to $NAMESPACE ==="

cd "k8s/overlays/$NAMESPACE"
kustomize edit set image "myregistry/myapp=myregistry/myapp:$REVISION"

kubectl apply -k .

if ! kubectl rollout status "deployment/myapp" -n "$NAMESPACE" --timeout="$TIMEOUT"; then
    echo "ERROR: rollout failed. Pod status:"
    kubectl get pods -l app=myapp -n "$NAMESPACE"
    echo "Recent events:"
    kubectl get events -n "$NAMESPACE" --sort-by='.lastTimestamp' | tail -20
    echo "Rolling back..."
    kubectl rollout undo "deployment/myapp" -n "$NAMESPACE"
    kubectl rollout status "deployment/myapp" -n "$NAMESPACE" --timeout="$TIMEOUT"
    exit 1
fi

echo "=== Deployed $REVISION ==="
```

Two things worth calling out.

**`git checkout --force "$REVISION"`, not `git pull`.** Checking out the exact commit [DeployHQ](https://www.deployhq.com) is deploying makes the manifests match the image tag. A `git pull` grabs whatever is currently on the branch head, which during a busy afternoon is not necessarily the commit that was built — that's how an environment ends up running one commit's image against another commit's manifests.

**The rollback waits.** The original mistake is to call `kubectl rollout undo` and exit immediately, which reports a completed rollback before it has actually completed. Waiting on the undo means a failed rollback surfaces as a failed deployment rather than a false all-clear.

**On the revision variable:** [DeployHQ](https://www.deployhq.com) exposes the commit being deployed to command contexts as `$DEPLOYHQ_REVISION`; the template-variable form documented for projects is `%endrev%` (`%shortendrev%` for the 8-character version). Which one interpolates depends on where it's read, so confirm it resolves on a staging deployment first — the `:?` guard turns a wrong guess into an immediate failure instead of a silent bad deploy.

On a Custom Action, delete the `git` lines and set `REPO_DIR=/data`. Everything else is identical.

## Wiring it into DeployHQ

Add the server ( **Servers → New Server** ), pick **Shell Server** , enter the SSH details, and set the deployment commands. Keep them to a single invocation:

```
/opt/deploy/myapp/deploy/k8s-deploy.sh
```

If you're using a Custom Action instead, the Commands box runs **one command per line** — it is not a shell script, so a pasted multi-line script fails with `syntax error: unexpected end of file`. Invoking a committed script sidesteps that entirely:

```
chmod +x /data/deploy/k8s-deploy.sh
/data/deploy/k8s-deploy.sh
```

> **Repo on GitHub?** [DeployHQ](https://www.deployhq.com) can [deploy from GitHub to your servers](https://www.deployhq.com/deploy-from-github). Add a second server for staging and the same project covers both environments.

## Rollback

The script rolls back automatically on a failed rollout. For a deploy that succeeded but turned out to be wrong:

```
kubectl rollout history deployment/myapp -n production
kubectl rollout undo deployment/myapp -n production
kubectl rollout undo deployment/myapp --to-revision=2 -n production
kubectl rollout status deployment/myapp -n production
```

Kubernetes keeps ten ReplicaSets by default (`revisionHistoryLimit`), so the previous version is available without a rebuild — the image is still in your registry, tagged by commit. The same immutable-tag rollback pattern on a single VPS is covered in [deploying Docker containers to a VPS with](https://www.deployhq.com/blog/docker-container-deployment-with-deployhq)[DeployHQ](https://www.deployhq.com) Shell Servers.

## Monitoring the rollout

```
kubectl get pods -l app=myapp -w
kubectl describe deployment myapp
kubectl logs -l app=myapp --tail=100
kubectl get events --sort-by='.lastTimestamp' | tail -20
```

When a rollout fails, `kubectl get events` is almost always more informative than pod logs — `ImagePullBackOff`, failed scheduling, and probe failures all surface there while the container may have produced no output at all. For the full command set, see our [kubectl cheatsheet](https://www.deployhq.com/cheatsheets/kubectl). For deployment-side visibility, [monitoring your deployments with DeployHQ](https://www.deployhq.com/blog/monitoring-your-deployments-with-deployhq-ensuring-smooth-releases) covers the notification side.

## Failure modes worth knowing

- **`docker build` in a build pipeline.** Docker isn't among its supported tools. Build in CI.
- **`kubectl apply -k k8s/...` on a Shell Server with no repo.** Repository files aren't uploaded for that protocol — the host has to fetch them. Custom Actions mount the repo at `/data`.
- **A pasted multi-line script in a Custom Action Commands box.** One command per line; call a committed script instead.
- **`kubectl rollout status` hanging.** Almost always a misconfigured readiness probe. `progressDeadlineSeconds` makes it fail fast.
- **Config changes that don't take effect.** A hand-edited ConfigMap doesn't restart pods. Use `configMapGenerator` so the hash change forces a new pod template.
- **Manifests and image from different commits.** Check out the deployed revision explicitly rather than pulling the branch head.

## Best practices summary

1. Build images in CI, release through [DeployHQ](https://www.deployhq.com) — rollback then never rebuilds
2. Tag with the commit SHA, and skip the mutable `latest` tag
3. Check out the deployed revision so manifests match the image
4. Use Kustomize overlays instead of duplicated manifests
5. Set `maxUnavailable: 0` and `progressDeadlineSeconds` for real zero-downtime rollouts
6. Scope RBAC to a namespaced deployer Role with no access to secrets
7. Wait on rollbacks, not just rollouts

## Getting started

1. Add image build and push to your CI, tagged with the commit SHA
2. Commit your manifests and `deploy/k8s-deploy.sh`
3. Add the server in [DeployHQ](https://www.deployhq.com) and point it at the script
4. [Deploy](https://www.deployhq.com) to staging and confirm the revision variable resolves
5. Promote to production

Wire this up against a staging namespace first, and only promote to production once the revision variable and the rollback path have both worked there. For teams newer to containers, our explainer on [what Docker is and how images, registries, and containers fit together](https://www.deployhq.com/blog/what-is-docker) is a good ten minutes before tackling Kubernetes.

* * *

Need help with Kubernetes deployments? Email us at [support@deployhq.com](mailto:support@deployhq.com) or follow [@deployhq](https://x.com/deployhq) for more deployment guides.

