fargate-deployer
v1.1.1
Published
Deploy containers to AWS ECS Fargate from a declarative manifest — long-running services behind an ALB, or EventBridge-scheduled tasks.
Readme
Fargate Deployer
Deploy a container to AWS ECS Fargate from a single declarative manifest — a long-running service behind an Application Load Balancer, or a set of EventBridge-scheduled tasks.
- uses: futuremoney/fargate-deployer@v1
with:
manifest: deploy/production.yaml
role-to-assume: arn:aws:iam::111122223333:role/github-actions-deployThat one step builds your image, pushes it to ECR, deploys the stack, and waits
until ECS reports the service healthy. Your repository needs a Dockerfile and
a manifest — no CDK app, no Node.js toolchain, no CloudFormation templates, no
aws ecs update-service scripting.
Contents
- Why this exists
- Quickstart
- What it creates, and what you bring
- Action inputs
- Action outputs
- Scheduled tasks
- Calling it as a reusable workflow
- Running it outside GitHub Actions
- Using it inside your own CDK app
- Documentation
Why this exists
Most teams running Fargate already own the expensive, long-lived pieces: a VPC, an ECS cluster, one or two load balancers, a wildcard certificate. What they deploy over and over is the cheap part — a task definition, a service, a target group, a listener rule.
Existing options make that repetitive part harder than it should be. Writing CDK
or Terraform per service means every team maintains infrastructure code to
express the same six resources. aws ecs update-service scripting handles a new
image but not a new port, health check or environment variable. Full-blown
platforms want to own your VPC.
This action takes the middle path: you own the shared infrastructure, it owns the per-service resources. You describe the service in about twenty lines of YAML, and it deploys the same way every time, in any account, for anybody.
It is a public, generalised version of an internal deployer that has shipped production services for a couple of years. Everything account-specific that used to be hardcoded is now a manifest field.
Quickstart
1. Bootstrap the account, once
The deployer runs on AWS CDK, which needs a one-time bootstrap per account and region:
npx cdk bootstrap aws://111122223333/us-east-12. Create a deploy role
Set up GitHub OIDC and a role your workflow can assume — no long-lived AWS keys
in your repository. docs/aws-setup.md has the trust
policy, the permissions policy, and a copy-pasteable CloudFormation template.
3. Write a manifest
deploy/production.yaml:
kind: Service
name: hello-api
account: "111122223333"
region: us-east-1
cluster:
name: my-cluster
network:
vpcId: vpc-0abc123def4567890
subnets:
- subnet-0abc123def4567890
- subnet-0fed987cba6543210
task:
cpu: 256
memory: 512
containerPort: 8080
environment:
NODE_ENV: production
secrets:
DATABASE_URL: "arn:aws:secretsmanager:us-east-1:111122223333:secret:prod/db-AbCdEf:url::"
loadBalancer:
listenerArn: arn:aws:elasticloadbalancing:us-east-1:111122223333:listener/app/my-alb/1234567890abcdef/abcdef1234567890
securityGroupId: sg-0abc123def4567890
hostHeaders: hello.example.com
healthCheck:
path: /healthCheck it before you push anything — this touches no AWS APIs:
npx fargate-deployer validate --manifest deploy/production.yaml4. Add the workflow
.github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write # required for OIDC
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: futuremoney/fargate-deployer@v1
with:
manifest: deploy/production.yaml
role-to-assume: arn:aws:iam::111122223333:role/github-actions-deployPush to main. More patterns — multiple environments, promoting an image from
staging to production, pull-request diffs — are in
examples/workflows/.
What it creates, and what you bring
The split is deliberate. Anything shared between services, expensive to replace, or dangerous to delete stays yours.
| You bring (referenced by ARN or ID) | It creates (owned by the stack) | | --- | --- | | VPC and subnets | ECS task definition | | ECS cluster | ECS service, or EventBridge rules | | Application Load Balancer and listener | ALB target group and listener rule | | ACM certificate | CloudWatch log group | | Secrets Manager secrets / SSM parameters | Task security group (if you don't supply one) | | IAM roles (optional) | Task and execution IAM roles (if you don't supply them) | | | Auto-scaling target and policies |
Everything in the right column is deleted cleanly when you delete the stack.
Nothing in the left column is ever modified, with one deliberate exception: when
you give loadBalancer.securityGroupId, an ingress rule is added so the load
balancer can reach your tasks. Set manageSecurityGroupRules: false to manage
that yourself.
Roles and security groups are the accessibility knob. Omit them and you get working defaults with the exact permissions the task needs — image pull, log writes, read access to precisely the secrets you listed, and ECS Exec. Supply them and the deployer treats them as immutable and touches nothing.
Action inputs
Only manifest is required.
What to deploy
| Input | Default | Description |
| --- | --- | --- |
| manifest | — | Required. Path to the manifest, e.g. deploy/production.yaml. |
| image | build one | Deploy this exact image instead of building. Skips build and push entirely. |
| command | deploy | deploy, diff, synth, validate or destroy. |
AWS authentication
| Input | Default | Description |
| --- | --- | --- |
| role-to-assume | — | IAM role ARN to assume via GitHub OIDC. Recommended. Needs permissions: id-token: write. |
| role-session-name | fargate-deployer | Session name for the assumed role. |
| role-external-id | — | External ID, if the role's trust policy requires one. |
| role-duration-seconds | 3600 | Session lifetime. Raise it if a slow rollout can outlast an hour. |
| aws-access-key-id | — | Static access key, when OIDC is not available. |
| aws-secret-access-key | — | Secret key that goes with it. |
| aws-session-token | — | Required when the key and secret are temporary STS credentials. |
| aws-region | from manifest | Override the region. |
Credentials go in with:, not secrets: — an action has no secrets: block.
The values are still masked in logs. If you would rather pass them as secrets:,
call the reusable workflow instead. A
worked example using access keys is in
examples/workflows/iam-access-keys.yml.
Four combinations are supported, and one non-combination:
| What you set | What happens |
| --- | --- |
| role-to-assume | OIDC. No stored credentials. Recommended. |
| aws-access-key-id + aws-secret-access-key | Static IAM user credentials. |
| …plus aws-session-token | Temporary STS credentials. |
| …plus role-to-assume | Assume that role from those keys — the usual pattern when a low-privilege CI user chains into a deploy role. |
| Nothing | The credential step is skipped, and whatever the job already has is used — credentials set by an earlier step, or a self-hosted runner's instance profile. |
Image build
| Input | Default | Description |
| --- | --- | --- |
| ecr-repository | manifest name | ECR repository to push to. |
| create-ecr-repository | true | Create the repository if it does not exist. |
| image-tag | github.sha | Tag for the built image. |
| push-latest | false | Also tag the image latest. |
| dockerfile | Dockerfile | Path to the Dockerfile, relative to context. |
| context | . | Docker build context. |
| build-args | — | KEY=VALUE per line. Visible in image history — not for secrets. |
| build-secrets | — | KEY=VALUE per line, read in the build via RUN --mount=type=secret,id=KEY. |
| platforms | from manifest | Build platform. Follows task.runtimePlatform.cpuArchitecture. |
| provenance | false | Attach SLSA provenance. Off because the resulting image index is not usable by ECS. |
Deployment behaviour
| Input | Default | Description |
| --- | --- | --- |
| bootstrap | false | Run cdk bootstrap first. Useful for a brand-new account; turn it off afterwards. |
| wait-for-stability | true | Wait for ECS to report the service stable, and print recent service events on failure. |
| cdk-args | — | Extra flags passed straight to the CDK CLI. |
| working-directory | . | Directory that manifest and context are relative to. |
| node-version | 20 | Node.js used to run the deployer itself, not your build. |
Action outputs
| Output | Description |
| --- | --- |
| image | Full image URI that was deployed |
| image-digest | Digest of the pushed image |
| stack-name | CloudFormation stack name |
| service-name | ECS service name (Service manifests) |
| cluster | ECS cluster deployed into |
| region / account | Target region and account from the manifest |
| kind | Service or ScheduledTasks |
| log-group | CloudWatch log group the tasks write to |
| alb-dns-name | Load balancer DNS name — the CNAME target for your DNS records |
| alb-hosted-zone-id | Load balancer hosted zone ID, for a Route 53 alias record |
Pointing DNS at the service
After a deploy the job summary lists the record to create for each host header you routed:
DNS
Point each hostname at the load balancer:
| Record | Type | Value | |---|---|---| |
hello.example.com| CNAME |my-alb-1234567890.us-east-1.elb.amazonaws.com|An apex domain cannot hold a CNAME — use a Route 53 alias A record to hosted zone
Z35SXDOTRQ7X7Kinstead.
The same values are available as outputs, so the records can be created by the workflow rather than by hand:
- id: deploy
uses: futuremoney/fargate-deployer@v1
with:
manifest: deploy/production.yaml
role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy
- name: Upsert the DNS record
run: |
aws route53 change-resource-record-sets \
--hosted-zone-id "$MY_ZONE" \
--change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "hello.example.com",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "${{ steps.deploy.outputs.alb-hosted-zone-id }}",
"DNSName": "${{ steps.deploy.outputs.alb-dns-name }}",
"EvaluateTargetHealth": true
}
}
}]
}'Resolving the name costs one elasticloadbalancing:DescribeLoadBalancers call
after the deploy. If the deploy role lacks that permission the step warns and
the outputs are empty — the deploy itself still succeeds.
Scheduled tasks
Change kind and the same action deploys cron jobs instead — one EventBridge
rule per entry, each starting a Fargate task:
kind: ScheduledTasks
name: data-sync
account: "111122223333"
region: us-east-1
cluster: { name: my-cluster }
network:
vpcId: vpc-0abc123def4567890
subnets: [subnet-0abc123def4567890]
task:
cpu: 256
memory: 512
tasks:
- name: nightly-sync
schedule: cron(0 6 * * ? *) # 06:00 UTC
command: ["node", "dist/jobs/sync.js"]
- name: hourly-prices
schedule: rate(1 hour)
command: ["node", "dist/jobs/prices.js"]See docs/scheduled-tasks.md for schedule syntax,
per-job overrides, and how to disable a job without deleting it.
Calling it as a reusable workflow
The action is the primary interface, but a workflow_call wrapper ships
alongside it for callers who prefer that shape — most often because AWS
credentials already arrive through a secrets: block, which an action cannot
accept:
jobs:
deploy:
uses: futuremoney/fargate-deployer/.github/workflows/fargate-deploy.yml@v1
with:
manifest: deploy/development.yaml
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_DEVELOPMENT }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_DEVELOPMENT }}It brings its own job, so there is no runs-on, no steps, and no
actions/checkout. Extra inputs it adds over the action: environment (to
apply a GitHub environment's protection rules) and runner. Extra secrets:
AWS_SESSION_TOKEN and BUILD_SECRETS.
Two things to know. Reusable workflows cannot be listed on the Marketplace —
only actions can — so this is referenced by path rather than by name. And it
adds a job boundary, so permissions and environment apply to the whole
called workflow rather than to one step.
See examples/workflows/reusable-workflow-with-secrets.yml.
Running it outside GitHub Actions
The action is a thin wrapper around a CLI, so the exact command CI runs also runs on your laptop, in GitLab CI, or from a Makefile:
npx fargate-deployer validate --manifest deploy/production.yaml
npx fargate-deployer diff --manifest deploy/production.yaml --image my-image:tag
npx fargate-deployer deploy --manifest deploy/production.yaml --image my-image:tag
npx fargate-deployer destroy --manifest deploy/production.yaml --image my-image:tagIt uses your ambient AWS credentials, the same as any other AWS CLI tool.
Running diff locally before pushing is the fastest way to see what a deploy
would change — it is the same synthesis CI performs, so the output matches.
Using it inside your own CDK app
If your repository already owns CDK infrastructure, import the stack into the app you have rather than deploying a second one:
import * as cdk from 'aws-cdk-lib';
import { createStack, loadManifest } from 'fargate-deployer';
const app = new cdk.App();
const infra = new MyInfraStack(app, 'my-infra');
const config = loadManifest('deploy/production.yaml');
config.task.environment.QUEUE_URL = infra.queue.queueUrl; // a CDK token
createStack({ app, config, image: process.env.IMAGE! });cdk deploy --all then covers both, and CDK turns that token into a real
cross-stack reference.
This is not just a preference. The CLI synthesises its own single-stack app, so any other stacks in your repository are invisible to it — they do not deploy, and nothing warns you. If you own CDK already, this is the mode you want.
docs/construct-mode.md covers the whole of it.
Documentation
| | | | --- | --- | | Manifest reference | Every field, its default, and when to set it | | AWS setup | Bootstrap, OIDC, IAM policies, prerequisites | | Scheduled tasks | Cron jobs in depth | | Architecture | What is created, how it is named, why | | Construct mode | Using the stacks inside a CDK app you already have | | Troubleshooting | Common failures and what they mean | | Contributing | Development setup and release process | | Security policy | Reporting a vulnerability, and the action's trust boundaries | | Changelog | What changed in each release |
