## Why ECS and EKS need a different DeployHQ deployment method

Most [DeployHQ](https://www.deployhq.com) projects deploy code by uploading files over SFTP or rsync to a server you own. AWS ECS and EKS don't work that way — there is nowhere to upload to. ECS expects you to push a container image to ECR and then call `aws ecs update-service --force-new-deployment`. EKS expects `kubectl apply` against a manifest, or a `kubectl rollout restart`. There is no file transfer step at all.

That mismatch is exactly what [DeployHQ Custom Actions](https://www.deployhq.com/support/servers/adding-a-server/custom-action) solve. A Custom Action is a server protocol type in [DeployHQ](https://www.deployhq.com) — you pick **Custom Action** instead of SFTP, SSH, or Shell Server when you add a server. Behind the scenes, [DeployHQ](https://www.deployhq.com) spins up a managed Docker container for the deployment, mounts your repository at `/data`, and runs whatever commands you configure. AWS CLI, `kubectl`, Terraform, the Google Cloud SDK — anything that runs in a container can run there, with no bastion to maintain.

The result is that every push becomes an immutable, audited deployment with [one-click rollback](https://www.deployhq.com/features/one-click-rollback) semantics, without you having to wire up a separate GitHub Action for every project or maintain an EC2 instance whose only job is to run `aws ecs update-service`.

```
flowchart LR
    A[git push] --> B[DeployHQ build pipeline]
    B --> C[Push image to ECR]
    C --> D[DeployHQ Custom Action container]
    D -->|aws ecs update-service| E[ECS service API]
    D -->|kubectl apply / rollout| F[EKS cluster API]
    E --> G[New tasks, drain old]
    F --> H[New pods, drain old]
```

This guide walks through the practical setup for both ECS and EKS using Custom Actions: the build pipeline that produces the ECR image, the Custom Action server that runs the AWS API calls, the rollback patterns, and the failure modes that bite people in production.

## Prerequisites

You need the following before you start:

- An active [DeployHQ](https://www.deployhq.com) account on a plan that supports build pipelines (see [DeployHQ pricing](https://www.deployhq.com/pricing) — the Basic plan and above)
- A repository connected to [DeployHQ](https://www.deployhq.com) ([deploy from GitHub](https://www.deployhq.com/deploy-from-github) or [deploy from GitLab](https://www.deployhq.com/deploy-from-gitlab) work the same way here)
- An AWS account with either an ECS cluster or an EKS cluster already provisioned
- An ECR repository for the application image
- An IAM user (or role + assume-role credentials) with these permissions: `ecr:GetAuthorizationToken`, `ecr:BatchGetImage`, `ecr:PutImage`, `ecs:UpdateService`, `ecs:DescribeServices`, `ecs:RegisterTaskDefinition`, `ecs:DescribeTaskDefinition`, and (for EKS) `eks:DescribeCluster`. The Custom Action container will use the access key for this user
- For EKS only: `kubectl` matching your cluster's minor version (provided by your chosen container image)

You do **not** need an EC2 bastion, a self-hosted runner, or any persistent infrastructure outside AWS itself. That's the point of Custom Actions: the deployment runs in a fresh container managed by [DeployHQ](https://www.deployhq.com), then disappears.

## Step 1: Add a Custom Action server in DeployHQ

In your [DeployHQ](https://www.deployhq.com) project, go to **Servers \> Add New Server** and pick **Custom Action** as the protocol. You'll be asked for:

- **Name** : e.g. `production-ecs` or `staging-eks`
- **Container image** : the Docker image the action runs inside. For ECS, an image with the AWS CLI v2 is enough (e.g. `amazon/aws-cli:latest` or a small custom image based on `alpine` with `awscli` installed). For EKS, you'll want an image that has both the AWS CLI and a matching `kubectl` (`bitnami/kubectl` plus `awscli`, or a custom image that bundles both)
- **Working directory** : leave at the default. Your repository contents are mounted at `/data` and that's the working directory the commands run in
- **Environment** : pick the deployment environment this server belongs to (e.g. Production)

Once the server is created, [DeployHQ](https://www.deployhq.com) will pull that image when a deployment runs and execute the commands you'll configure in Step 3 against it. The repository is available at `/data`, so a `kubectl apply -f /data/k8s/` works exactly as you'd expect.

If you already use [DeployHQ build pipelines](https://www.deployhq.com/features/build-pipelines), the mental model is similar: a Custom Action is essentially a build-pipeline-style container, but it runs as the deployment step instead of the build step.

## Step 2: Build and push the image with a DeployHQ build pipeline

The build pipeline runs on DeployHQ's infrastructure before the deployment phase. For containerised apps, it builds the Docker image and pushes it to ECR, tagged with the commit SHA so every deployment is traceable.

Add the following commands to the build pipeline (Project Settings \> Build Pipeline). Variables in `${...}` are [DeployHQ deployment variables](https://www.deployhq.com/support/build-pipelines):

```
# Authenticate to ECR
aws ecr get-login-password --region ${AWS_REGION} \
  | docker login --username AWS --password-stdin \
    ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com

# Build with the commit SHA as the tag (immutable, traceable)
IMAGE=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/myapp:${REVISION}

docker build -t $IMAGE .
docker push $IMAGE
```

Set `AWS_REGION`, `AWS_ACCOUNT_ID`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` as **environment variables** under Project Settings \> Environment Variables. Mark the secret keys as protected so they don't appear in build logs. For more on keeping deployment credentials safe, see our guide on [secrets management for modern deployments](https://www.deployhq.com/blog/secrets-management-for-modern-deployments-keeping-your-credentials-out-of-git).

Why tag with `${REVISION}` (the commit SHA) rather than `:latest`? Because rolling back is then a single CLI call against a specific, named image. If you only ever push `:latest`, you've thrown away your rollback target — a classic mistake that turns a 30-second incident into a 30-minute one. For more on this pattern, our writeup on [Dockerizing your application for](https://www.deployhq.com/blog/dockerizing-your-application-best-practices-for-deployhq-deployments)[DeployHQ](https://www.deployhq.com) deployments covers tag hygiene in detail.

## Step 3: Configure the Custom Action commands for ECS

On the ECS Custom Action server, add the deployment commands. These run inside the container image you picked in Step 1, with `/data` containing the repository and the environment variables from the project settings already set.

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

CLUSTER=production
SERVICE=myapp
REGION=${AWS_REGION}
IMAGE=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/myapp:${REVISION}

# Register a new task definition revision pointing at this commit's image
NEW_TASK_DEF=$(aws ecs describe-task-definition \
  --task-definition myapp \
  --region "$REGION" \
  --query 'taskDefinition' \
  | jq --arg img "$IMAGE" '.containerDefinitions[0].image = $img
      | del(.taskDefinitionArn, .revision, .status,
            .requiresAttributes, .compatibilities,
            .registeredAt, .registeredBy)')

NEW_REVISION=$(aws ecs register-task-definition \
  --cli-input-json "$NEW_TASK_DEF" \
  --region "$REGION" \
  --query 'taskDefinition.taskDefinitionArn' --output text)

# Tell the service to use the new revision
aws ecs update-service \
  --cluster "$CLUSTER" \
  --service "$SERVICE" \
  --task-definition "$NEW_REVISION" \
  --region "$REGION"

# Wait until the rollout is healthy. This is the critical line most guides skip.
aws ecs wait services-stable \
  --cluster "$CLUSTER" \
  --services "$SERVICE" \
  --region "$REGION"

echo "ECS deployment complete: $SERVICE on revision $NEW_REVISION"
```

Two things to call out:

The `aws ecs wait services-stable` call is what turns this from a fire and forget command into a real deployment. Without it, [DeployHQ](https://www.deployhq.com) will report success the moment ECS _accepts_ the update — not when the new tasks are actually serving traffic. The wait blocks (up to 10 minutes by default) until the desired task count equals the running count and the deployment is `PRIMARY`. If the new task definition fails its health check, the wait times out non-zero and the deployment fails loudly. That's what you want.

Registering a new task definition revision per deploy (rather than just `--force-new-deployment` against `:latest`) gives you an explicit ECS task definition history you can roll back to with one command, separate from the image registry. This composes naturally with [zero-downtime deployments](https://www.deployhq.com/features/zero-downtime-deployments) — ECS will drain old tasks only after the new ones pass their health checks.

For the full AWS CLI v2 patterns used here — ECR auth, ECS rolling deploys with `services-stable` waits, S3 sync, SSM parameter fetch, log tailing — see our [AWS CLI cheatsheet](https://www.deployhq.com/cheatsheets/aws-cli).

## Step 4: Configure the Custom Action commands for EKS

For EKS, the Custom Action uses `kubectl`. The container image needs both the AWS CLI (to fetch a kubeconfig from the cluster) and a `kubectl` matching your cluster's minor version.

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

CLUSTER=production
NAMESPACE=production
DEPLOYMENT=myapp
REGION=${AWS_REGION}
IMAGE=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/myapp:${REVISION}

# Fetch a kubeconfig for the cluster (writes ~/.kube/config in the container)
aws eks update-kubeconfig --name "$CLUSTER" --region "$REGION"

# Option A: GitOps-style — apply manifests from the repo at /data
kubectl apply -f /data/k8s/ -n "$NAMESPACE"

# Update the running deployment with the new image (works even if the manifest
# pins :latest — the SHA tag forces a real pull)
kubectl set image deployment/${DEPLOYMENT} \
  ${DEPLOYMENT}=${IMAGE} \
  -n ${NAMESPACE}

# Wait for the rollout to actually complete — same logic as `ecs wait services-stable`
kubectl rollout status deployment/${DEPLOYMENT} \
  -n ${NAMESPACE} \
  --timeout=10m

echo "EKS rollout complete: ${DEPLOYMENT}"
```

`kubectl rollout status` is the Kubernetes equivalent of `ecs wait services-stable`. It blocks until the new ReplicaSet is fully ready, and exits non-zero if the rollout fails (e.g., `ImagePullBackOff` or readiness probe failures), which propagates back to [DeployHQ](https://www.deployhq.com) as a failed deployment.

If your manifests live in the repo (a [GitOps-style](https://www.deployhq.com/blog/what-is-gitops) workflow), the `/data` mount means `kubectl apply -f /data/k8s/` is the entire integration — you don't have to copy files anywhere first. That's the main practical advantage of Custom Actions over a self-hosted runner.

For the kubectl commands used above — context switching, deployments, rollouts, rollbacks, debugging — see our [kubectl cheatsheet](https://www.deployhq.com/cheatsheets/kubectl).

## Step 5: Set environment variables for the Custom Action

The Custom Action container only sees the environment variables you give it. Under **Servers \> [Your Custom Action] \> Configuration Variables**, add:

```
AWS_ACCESS_KEY_ID <protected>
AWS_SECRET_ACCESS_KEY <protected>
AWS_REGION eu-west-1
AWS_ACCOUNT_ID 123456789012
```

For EKS, the kubeconfig is generated on the fly by `aws eks update-kubeconfig`, so you don't need to ship one. If you'd rather mount a pre-built kubeconfig, set `KUBECONFIG=/data/.kube/config` and check the encrypted file into the repo.

Mark `AWS_SECRET_ACCESS_KEY` as protected so it doesn't appear in deployment logs.

## Step 6: Roll back when things go wrong

Both ECS and EKS support fast rollbacks from a previous good state — but you have to set yourself up for them.

**ECS rollback** — re-deploy the previous task definition revision:

```
aws ecs update-service \
  --cluster production \
  --service myapp \
  --task-definition myapp:42 # the previous revision
aws ecs wait services-stable --cluster production --services myapp
```

**EKS rollback** — use Kubernetes' built-in rollout history:

```
# Roll back to the previous revision
kubectl rollout undo deployment/myapp -n production

# Or to a specific revision
kubectl rollout undo deployment/myapp --to-revision=7 -n production

kubectl rollout status deployment/myapp -n production
```

The [DeployHQ](https://www.deployhq.com) side of this is just another Custom Action — create a Rollback Custom Action server that runs the relevant command, with a parameter for the target revision, and trigger it on demand from the deployments page. That gives you a consistent rollback story across containerised and traditional file-based projects in the same [DeployHQ](https://www.deployhq.com) account. For a deeper look at rollback design, see our writeup on [automated rollback strategies when your deployment goes sideways](https://www.deployhq.com/blog/automated-rollback-strategies-when-your-deployment-goes-sideways).

## Failure modes nobody warns you about

Things that will go wrong in production:

- **ECR auth tokens expire after 12 hours.** If you cached a `docker login` somewhere and skipped re-running it in the build pipeline, the next deploy fails with `denied: Your authorization token has expired`. Always include `aws ecr get-login-password` at the start of every build run — Custom Actions get a fresh container every time, so this only matters in the build pipeline phase.
- **Task definitions can't reference images that don't exist.** Push first, register the task def second, update the service third. Race conditions here cause `CannotPullContainerError` because ECS pulls from the registry before checking your code.
- **`kubectl rollout status` will hang for 10 minutes if the readiness probe is misconfigured.** A pod that starts but never goes Ready blocks the rollout indefinitely. Set `progressDeadlineSeconds: 300` on the deployment spec to fail fast.
- **EKS auth fails after IAM role rotation.** The `aws-auth` ConfigMap in the cluster caches role mappings. If you rotate the IAM credentials the Custom Action uses, you must update the ConfigMap in the cluster too — not just the IAM user/role itself. Symptom: `error: You must be logged in to the server (Unauthorized)` from a previously-working deployment.
- **Private VPCs with no public AWS API endpoints.** ECS and EKS APIs are normally reachable from anywhere, but if you've locked the cluster down to VPC endpoints only, the Custom Action container won't be able to reach them. In that case use a Shell Server inside the VPC instead — see the next section.

## When to use a Shell Server instead of Custom Actions

Custom Actions cover the common case (public AWS APIs, stateless deployments, no long-lived agents needed), but a [Shell Server](https://www.deployhq.com/support/ssh-commands) is still the right answer in two situations.

The first is a **private VPC with no public AWS API access** — for example, an EKS cluster whose control plane is reachable only from inside the VPC. A DeployHQ-managed Custom Action container can't reach it. Provision a small EC2 bastion inside the VPC, install the AWS CLI and `kubectl` on it, register it as a Shell Server in [DeployHQ](https://www.deployhq.com), and have your SSH commands run from there. This pattern also works behind corporate firewalls — see our writeup on [deploying to a private network behind a firewall](https://www.deployhq.com/blog/deploying-to-a-private-network-a-guide-to-using-deployhq-behind-a-firewall) for the network agent setup.

The second is a **long-lived agent or self-hosted runner**. If you have a single host that runs scheduled jobs, owns kubeconfig caches across deployments, or holds state between runs (e.g. a Terraform state lock helper), a Shell Server fits better than a stateless Custom Action. The trade-off is that you now own a server — patching, hardening, monitoring, and IAM rotation are on you. For most ECS and EKS workloads, that's overhead you don't need.

## Why this beats wiring up GitHub Actions or a custom Lambda

Plenty of teams write GitHub Actions or a Lambda triggered by an SNS topic to do the same job. Three reasons to use [DeployHQ](https://www.deployhq.com) instead:

1. **One audit log across every project.** ECS, EKS, plain VPS, shared hosting, static sites — all show up in the same deployment history with the same rollback UX. New engineers don't need to learn five different CI flavours.
2. **Deployment notifications are baked in.** Slack, email, and webhooks fire on the same events for every project type. No re-implementing notification glue per pipeline.
3. **The build pipeline and the Custom Action are the same primitive.** You don't need a separate build server concept — the pipeline already runs on DeployHQ's infrastructure, and the Custom Action only handles the AWS-specific mutation step. That separation makes rollbacks cleaner: the image is already in ECR, so rolling back never needs to rebuild.

For more on how the pieces fit together, see our [step-by-step guide to building a CI/CD pipeline from scratch with DeployHQ](https://www.deployhq.com/blog/building-a-ci-cd-pipeline-from-scratch-with-deployhq-a-step-by-step-guide). Container-image hygiene matters here too — our guide on [12-factor app methodology with DeployHQ](https://www.deployhq.com/blog/implementing-12-factor-app-methodology-with-deployhq) covers config, dependencies, and process patterns that translate directly to ECS task definitions and EKS pod specs. And for the broader background on rolling deploys, blue/green, and canary patterns, our writeup on [zero-downtime deployment strategies for modern applications](https://www.deployhq.com/blog/zero-downtime-deployment-strategies-for-modern-applications) is the natural follow-up.

## Wrapping up

Custom Actions are the bridge between DeployHQ's deployment model and AWS's API-driven container services. The pattern is always the same: a build pipeline produces an artefact in ECR, and a Custom Action runs the [AWS CLI](https://aws.amazon.com/cli/) or `kubectl` command that promotes it. Add `aws ecs wait services-stable` or `kubectl rollout status` to make failed rollouts surface as failed [DeployHQ](https://www.deployhq.com) deployments, and tag every image with the commit SHA so rollback is one command.

If you're new to [DeployHQ](https://www.deployhq.com), [sign up for a free trial](https://www.deployhq.com/signup) and try this with a sample ECS service first — it's the cheapest way to validate the workflow before pointing it at production. For containerised app fundamentals before you start, our explainer on [what Docker is and how images, registries, and containers fit together](https://www.deployhq.com/blog/what-is-docker) is worth ten minutes.

* * *

Questions or hit a snag wiring up the Custom Action? Email us at [support@deployhq.com](mailto:support@deployhq.com) or ping [@deployhq](https://x.com/deployhq) — we're happy to help.

