Why ECS and EKS need a different DeployHQ deployment method
Most DeployHQ 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 solve. A Custom Action is a server protocol type in DeployHQ — you pick Custom Action instead of SFTP, SSH, or Shell Server when you add a server. Behind the scenes, DeployHQ 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 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 account on a plan that supports build pipelines (see DeployHQ pricing — the Basic plan and above)
- A repository connected to DeployHQ (deploy from GitHub or 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:
kubectlmatching 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, then disappears.
Step 1: Add a Custom Action server in DeployHQ
In your DeployHQ project, go to Servers > Add New Server and pick Custom Action as the protocol. You'll be asked for:
- Name: e.g.
production-ecsorstaging-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:latestor a small custom image based onalpinewithawscliinstalled). For EKS, you'll want an image that has both the AWS CLI and a matchingkubectl(bitnami/kubectlplusawscli, or a custom image that bundles both) - Working directory: leave at the default. Your repository contents are mounted at
/dataand 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 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, 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:
# 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.
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 DeployHQ 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 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 — ECS will drain old tasks only after the new ones pass their health checks.
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 as a failed deployment.
If your manifests live in the repo (a GitOps-style 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.
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 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 account. For a deeper look at rollback design, see our writeup on 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 loginsomewhere and skipped re-running it in the build pipeline, the next deploy fails withdenied: Your authorization token has expired. Always includeaws ecr get-login-passwordat 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
CannotPullContainerErrorbecause ECS pulls from the registry before checking your code. kubectl rollout statuswill hang for 10 minutes if the readiness probe is misconfigured. A pod that starts but never goes Ready blocks the rollout indefinitely. SetprogressDeadlineSeconds: 300on the deployment spec to fail fast.- EKS auth fails after IAM role rotation. The
aws-authConfigMap 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 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, 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 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 instead:
- 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.
- 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.
- 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. Container-image hygiene matters here too — our guide on 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 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 or kubectl command that promotes it. Add aws ecs wait services-stable or kubectl rollout status to make failed rollouts surface as failed DeployHQ deployments, and tag every image with the commit SHA so rollback is one command.
If you're new to DeployHQ, sign up for a free trial 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 is worth ten minutes.
Questions or hit a snag wiring up the Custom Action? Email us at support@deployhq.com or ping @deployhq — we're happy to help.