New to Kestra?
Use blueprints to kickstart your first workflows.
Automate GitOps deployments to AWS EKS. Clone a repo, build with Kaniko, push to ECR, verify the rollout with kubectl and report the result to Discord.
id: aws-eks-gitops-deploy
namespace: company.team
description: |
Deploy a containerized application to AWS EKS on every Git push: clone the
repository into a shared working directory, build the image with Kaniko (no
Docker daemon) and push it to Amazon ECR, apply the Kubernetes manifests,
wait for the rolling update to finish, and report the result to Discord.
If any step fails, capture pod status, logs and cluster events and alert
Discord with that evidence.
concurrency:
behavior: QUEUE
limit: 1
inputs:
- id: git_repo
type: STRING
defaults: "https://github.com/your-username/eks-gitops-deploy.git"
description: HTTPS URL of the Git repository to build and deploy. It must
contain a Dockerfile and Kubernetes manifests in k8s/.
- id: git_branch
type: STRING
defaults: "main"
description: Branch to check out, build and deploy.
- id: aws_region
type: STRING
defaults: "ap-south-2"
description: AWS region where the ECR repository and the EKS cluster live.
- id: ecr_registry
type: STRING
defaults: "your-account-id.dkr.ecr.ap-south-2.amazonaws.com"
description: ECR registry host, in the form
<account-id>.dkr.ecr.<region>.amazonaws.com. The image is pushed to
<registry>/eks-gitops-deploy:<execution id>.
- id: eks_cluster_name
type: STRING
defaults: "eks-gitops-deploy-cluster"
description: Name of the EKS cluster to deploy to. It is used to generate the
kubeconfig with aws eks update-kubeconfig.
- id: k8s_namespace
type: STRING
defaults: "production"
description: Kubernetes namespace to deploy into. It is created if it does not
exist, and the namespace in the k8s/ manifests is rewritten to match.
- id: notify_discord
type: BOOL
defaults: true
description: Post the success embed to Discord after a rollout. Requires the
DISCORD_WEBHOOK_URL secret or KV pair; set to false to skip it. Failures
are always reported by the error handler.
tasks:
- id: working_dir
type: io.kestra.plugin.core.flow.WorkingDirectory
description: One shared directory so the clone, the Kaniko build and the kubectl
deployment all see the same files, including the manifest edited with the
new image tag.
tasks:
- id: clone_repository
type: io.kestra.plugin.git.Clone
description: Check out the repository at the configured branch.
url: "{{ inputs.git_repo | trim }}"
branch: "{{ inputs.git_branch | trim }}"
- id: build_and_push_image
type: io.kestra.plugin.scripts.shell.Commands
description: Build the Dockerfile with the Kaniko executor in userspace (no
privileged Docker daemon or mounted docker.sock) and push it to ECR,
tagged with the execution ID so every deployment maps to exactly one
run.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: gcr.io/kaniko-project/executor:debug
env:
AWS_ACCESS_KEY_ID: "{{ kv(key='AWS_ACCESS_KEY_ID', errorOnMissing=false) ??
secret('AWS_ACCESS_KEY_ID') }}"
AWS_SECRET_ACCESS_KEY: "{{ kv(key='AWS_SECRET_ACCESS_KEY', errorOnMissing=false)
?? secret('AWS_SECRET_ACCESS_KEY') }}"
AWS_REGION: "{{ inputs.aws_region | trim }}"
commands:
- /kaniko/executor --context dir://. --dockerfile Dockerfile
--destination {{ inputs.ecr_registry | trim }}/eks-gitops-deploy:{{
execution.id }} --custom-platform=linux/amd64 --ignore-path=/tmp
- id: deploy_and_verify_rollout
type: io.kestra.plugin.scripts.shell.Commands
description: Connect to EKS, inject the new image tag into k8s/deployment.yaml,
apply the manifests, block on kubectl rollout status until the new
pods are ready, then write the Discord success embed (LoadBalancer
URL, pod placement and replica count) to a file for the notification
step.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: alpine/k8s:1.31.0
env:
AWS_ACCESS_KEY_ID: "{{ kv(key='AWS_ACCESS_KEY_ID', errorOnMissing=false) ??
secret('AWS_ACCESS_KEY_ID') }}"
AWS_SECRET_ACCESS_KEY: "{{ kv(key='AWS_SECRET_ACCESS_KEY', errorOnMissing=false)
?? secret('AWS_SECRET_ACCESS_KEY') }}"
AWS_DEFAULT_REGION: "{{ inputs.aws_region | trim }}"
outputFiles:
- discord_success.json
commands:
- aws eks update-kubeconfig --region {{ inputs.aws_region | trim }}
--name {{ inputs.eks_cluster_name | trim }}
- kubectl create namespace {{ inputs.k8s_namespace | trim }}
--dry-run=client -o yaml | kubectl apply -f -
- sed -i "s|IMAGE_PLACEHOLDER|{{ inputs.ecr_registry | trim
}}/eks-gitops-deploy:{{ execution.id }}|g" k8s/deployment.yaml
- 'sed -i "s|namespace: production|namespace: {{ inputs.k8s_namespace
| trim }}|g" k8s/*.yaml'
- kubectl apply -f k8s/
- kubectl rollout status deployment/eks-gitops-deploy -n {{
inputs.k8s_namespace | trim }} --timeout=420s
- |
LB_HOST=$(kubectl get svc eks-gitops-deploy-service -n {{ inputs.k8s_namespace | trim }} -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' 2>/dev/null || echo "")
if [ -z "$LB_HOST" ]; then
LB_HOST=$(kubectl get svc eks-gitops-deploy-service -n {{ inputs.k8s_namespace | trim }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || echo "Pending...")
fi
POD_SUMMARY=$(kubectl get pods -n {{ inputs.k8s_namespace | trim }} -l app=eks-gitops-deploy -o wide --no-headers 2>/dev/null | awk '{print "• "$1" ("$3", Ready: "$2") on Node: "$7}' | head -n 5)
REPLICAS=$(kubectl get deployment eks-gitops-deploy -n {{ inputs.k8s_namespace | trim }} -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "2/2")
jq -n \
--arg cluster "{{ inputs.eks_cluster_name }}" \
--arg region "{{ inputs.aws_region }}" \
--arg ns "{{ inputs.k8s_namespace | trim }}" \
--arg img "{{ inputs.ecr_registry }}/eks-gitops-deploy:{{ execution.id }}" \
--arg execId "{{ flow.id }} - {{ execution.id }}" \
--arg lb "http://$LB_HOST" \
--arg pods "$POD_SUMMARY" \
--arg reps "$REPLICAS Ready" \
'{
username: "eks-gitops-deploy Bot",
avatar_url: "https://kestra.io/logo.png",
embeds: [
{
title: "🚀 AWS EKS Deployment SUCCESS",
description: "**Application**: `eks-gitops-deploy` successfully deployed and rolled out to **{{ inputs.k8s_namespace | trim }}** on AWS EKS.",
color: 3066993,
fields: [
{
name: "🌐 Live Service LoadBalancer URL",
value: $lb,
inline: false
},
{
name: "📦 Container Image",
value: ("`" + $img + "`"),
inline: false
},
{
name: "🎯 Infrastructure Details",
value: ("• **Cluster**: `" + $cluster + "`\n• **Namespace**: `" + $ns + "`\n• **Region**: `" + $region + "`"),
inline: true
},
{
name: "📊 Pod Health & Sizing",
value: ("• **Replicas**: `" + $reps + "`\n• **CPU/RAM**: `100m-300m / 160Mi-320Mi`\n• **Probes**: Liveness & Readiness UP"),
inline: true
},
{
name: "📋 Active Pod Instances",
value: ("```text\n" + (if $pods != "" then $pods else "All pods healthy" end) + "\n```"),
inline: false
},
{
name: "🔗 Telemetry Endpoints Hub",
value: ("[Discovery Map](" + $lb + "/) • [Live System Stats](" + $lb + "/api/system) • [Actuator Health](" + $lb + "/actuator/health) • [App Info](" + $lb + "/api/info)"),
inline: false
}
],
footer: {
text: ("Kestra GitOps Orchestrator • Execution: " + $execId)
},
timestamp: (now | todate)
}
]
}' > discord_success.json
- id: check_discord_enabled
type: io.kestra.plugin.core.flow.If
description: Only post the success embed when notifications are enabled, so the
flow also works for teams that have no Discord webhook.
condition: "{{ inputs.notify_discord }}"
then:
- id: send_success_embed
type: io.kestra.plugin.scripts.shell.Commands
description: POST the success embed built by the deploy task (LoadBalancer URL,
image, replicas and pod placement) to the Discord webhook.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: alpine/k8s:1.31.0
inputFiles:
discord_success.json: "{{
outputs.deploy_and_verify_rollout.outputFiles['discord_success.json\
'] }}"
env:
DISCORD_WEBHOOK_URL: "{{ kv(key='DISCORD_WEBHOOK_URL', errorOnMissing=false) ??
secret('DISCORD_WEBHOOK_URL') }}"
commands:
- 'curl -s -H "Content-Type: application/json" -X POST -d
@discord_success.json "$DISCORD_WEBHOOK_URL"'
else:
- id: log_notifications_disabled
type: io.kestra.plugin.core.log.Log
description: Record that the Discord notification was skipped on purpose.
message: "Deployment of {{ inputs.ecr_registry | trim }}/eks-gitops-deploy:{{
execution.id }} finished. Discord notifications are disabled for this
run."
# Error handler
errors:
- id: capture_pod_diagnostics_and_alert
type: io.kestra.plugin.scripts.shell.Commands
description: When any task fails, collect pod status, recent container logs and
cluster events and send them to Discord, because a failed rollout is only
useful to the team if the evidence arrives with the alert.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: alpine/k8s:1.31.0
env:
AWS_ACCESS_KEY_ID: "{{ kv(key='AWS_ACCESS_KEY_ID', errorOnMissing=false) ??
secret('AWS_ACCESS_KEY_ID') }}"
AWS_SECRET_ACCESS_KEY: "{{ kv(key='AWS_SECRET_ACCESS_KEY', errorOnMissing=false)
?? secret('AWS_SECRET_ACCESS_KEY') }}"
AWS_DEFAULT_REGION: "{{ inputs.aws_region | trim }}"
DISCORD_WEBHOOK_URL: "{{ kv(key='DISCORD_WEBHOOK_URL', errorOnMissing=false) ??
secret('DISCORD_WEBHOOK_URL') }}"
commands:
- |
set +e
aws eks update-kubeconfig --region {{ inputs.aws_region | trim }} --name {{ inputs.eks_cluster_name | trim }} || true
echo "=== CAPTURING DEPLOYMENT DIAGNOSTICS ==="
POD_STATUS=$(kubectl get pods -n {{ inputs.k8s_namespace | trim }} -l app=eks-gitops-deploy 2>&1 | head -n 6 | head -c 800)
CRASH_LOGS=$(kubectl logs deployment/eks-gitops-deploy -n {{ inputs.k8s_namespace | trim }} --tail=15 --all-containers=true 2>&1 | head -c 800)
EVENTS=$(kubectl get events -n {{ inputs.k8s_namespace | trim }} --sort-by='.metadata.creationTimestamp' 2>&1 | tail -n 6 | head -c 800)
[ -z "$POD_STATUS" ] && POD_STATUS="No pod status available"
[ -z "$CRASH_LOGS" ] && CRASH_LOGS="No container logs available"
[ -z "$EVENTS" ] && EVENTS="No recent events"
jq -n \
--arg cluster "{{ inputs.eks_cluster_name }}" \
--arg region "{{ inputs.aws_region }}" \
--arg ns "{{ inputs.k8s_namespace | trim }}" \
--arg img "{{ inputs.ecr_registry }}/eks-gitops-deploy:{{ execution.id }}" \
--arg execId "{{ flow.id }} - {{ execution.id }}" \
--arg podStatus "$POD_STATUS" \
--arg crashLogs "$CRASH_LOGS" \
--arg events "$EVENTS" \
'{
username: "eks-gitops-deploy Alert",
avatar_url: "https://kestra.io/logo.png",
embeds: [
{
title: "🚨 AWS EKS Deployment FAILED",
description: ("Deployment failed during verification/rollout on cluster **" + $cluster + "**."),
color: 15158332,
fields: [
{
name: "🎯 Infrastructure & Context",
value: ("• **Cluster**: `" + $cluster + "`\n• **Namespace**: `" + $ns + "`\n• **Region**: `" + $region + "`\n• **Attempted Image**: `" + $img + "`"),
inline: false
},
{
name: "📋 Pod Health & Diagnostics",
value: ("```text\n" + $podStatus + "\n```"),
inline: false
},
{
name: "📜 Recent Crash / Error Logs",
value: ("```text\n" + $crashLogs + "\n```"),
inline: false
},
{
name: "⚠️ Cluster Events",
value: ("```text\n" + $events + "\n```"),
inline: false
}
],
footer: {
text: ("Kestra GitOps Orchestrator • Execution: " + $execId)
},
timestamp: (now | todate)
}
]
}' > /tmp/discord_failure.json
if [ -n "$DISCORD_WEBHOOK_URL" ]; then
echo "Sending incident embed to Discord..."
curl -s -H "Content-Type: application/json" -X POST -d @/tmp/discord_failure.json "$DISCORD_WEBHOOK_URL"
else
echo "Warning: DISCORD_WEBHOOK_URL is not set!"
fi
triggers:
- id: github_webhook
type: io.kestra.plugin.core.trigger.Webhook
description: GitHub (or your CI) POSTs here on every push to start a deployment.
The URL is keyed, so treat the key as a secret.
key: "{{ kv(key='DEPLOY_WEBHOOK_KEY', errorOnMissing=false) ??
secret('DEPLOY_WEBHOOK_KEY') }}"
outputs:
- id: deployed_image
type: STRING
description: Full image reference that was built, pushed to ECR and deployed,
e.g.
123456789012.dkr.ecr.ap-south-2.amazonaws.com/eks-gitops-deploy:3Fz8Qk1xYvLm.
value: "{{ inputs.ecr_registry | trim }}/eks-gitops-deploy:{{ execution.id }}"
- id: deployed_namespace
type: STRING
description: Kubernetes namespace the application was deployed into, e.g. production.
value: "{{ inputs.k8s_namespace | trim }}"
Turn a Git push into a verified AWS EKS deployment without a CI server or a privileged Docker daemon. The flow builds the image with Kaniko in userspace, pushes it to Amazon ECR with an immutable per-execution tag, applies your manifests, waits for the rolling update and reports the outcome to Discord.
github_webhook trigger starts the flow on every push, and concurrency with QUEUE runs rapid pushes one at a time.clone_repository checks out git_repo at git_branch inside a WorkingDirectory.build_and_push_image runs the Kaniko executor and pushes <ecr_registry>/eks-gitops-deploy:<execution.id> to ECR.deploy_and_verify_rollout connects to EKS, injects the image tag and namespace into k8s/, applies the manifests and blocks on kubectl rollout status.check_discord_enabled (If) posts the success embed when the notify_discord input is true, otherwise it logs that it was skipped.errors handler sends pod status, logs and cluster events to Discord.A shell script can run the same commands, but it cannot queue overlapping pushes, keep a per-run history or route failures to a dedicated handler.
eks-gitops-deploy, and a Git repository with a Dockerfile and k8s/ manifests whose Deployment image is IMAGE_PLACEHOLDER.Each can be a Kestra Secret or a KV pair.
AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY: credentials with ECR push and EKS access.DEPLOY_WEBHOOK_KEY: any random string used as the last segment of the webhook URL.DISCORD_WEBHOOK_URL: needed only when notify_discord is true.git_repo, ecr_registry (host only), eks_cluster_name and aws_region.Parallel task to smoke-test the new LoadBalancer URL, or swap Discord for Slack with the matching plugin.