Skip to content
GantryCD

AWS

At a glance — GantryCD’s backend assumes IAM roles in your accounts (sts:AssumeRole, guarded by an external ID) and hands the resulting temporary credentials to the run as named profiles. You store no AWS keys.

When to use it

Add an aws cloud integration to any stack whose OpenTofu manages AWS resources. A stack has one AWS integration, and that integration lists one role per account the stack reaches — a stack spanning two accounts has one integration with two roles.

Configure

  1. Create an IAM role in your AWS account with whatever permissions your OpenTofu needs.
  2. Add a trust policy that allows GantryCD’s backend principal to assume it, conditioned on the external ID GantryCD shows you (this prevents the confused-deputy problem).
  3. In the stack’s Cloud integrations, tick AWS and add a role with a name and the role ARN. Repeat for each further account the stack reaches.

The operator must have AWS in RUNTIME_PROVIDERS and a backend identity that the role’s trust policy accepts — see Configuration.

Several roles, selected by profile

Each role’s name is how your OpenTofu picks it. GantryCD writes every role in the stack’s AWS integration into a shared-credentials file — one [name] section each — and points the AWS SDK at it, so profiles behave exactly as they do on your workstation:

provider "aws" {}                      # uses the role named "default"

provider "aws" {
  alias   = "network"
  profile = "network"                  # uses the role named "network"
}

A role named default is what a bare provider "aws" {} block picks up, so a single-role stack needs no HCL change at all. Names may contain letters, digits and _ . @ -, and no prefix is reserved.

Repeat --aws-role once per role. Up to 50 roles are allowed:

gantrycli cloud-integrations create --org <org> --stack <stack> --provider aws \
  --aws-role name=default,role_arn=arn:aws:iam::111122223333:role/AppRole \
  --aws-role name=network,role_arn=arn:aws:iam::444455556666:role/NetworkRole

Update replaces the whole list. create is once per stack — after that, adding, retargeting and removing a role are all update, and the roles you pass become the stack’s complete set. A role you leave out is removed:

# Keep "default", drop "network", add "shared" — one call.
gantrycli cloud-integrations update aws --org <org> --stack <stack> \
  --aws-role name=default,role_arn=arn:aws:iam::111122223333:role/AppRole \
  --aws-role name=shared,role_arn=arn:aws:iam::999999999999:role/SharedRole

Region is yours. GantryCD writes no region into the credentials. Set it in the provider block, or with the AWS_REGION environment variable on the stack — the same resolution order the AWS CLI uses.

Name one default if you want a default. GantryCD never picks an ambient identity for you — not even when a stack has only one role. Which role a bare consumer should use is yours to state, not something inferred from a count: a stack whose single role is called readonly has not asked for it to become the identity behind every aws command in its hooks.

So the rule is AWS’s own, and it reads the same whether you have one role or fifty: a role named default is what a bare provider "aws" {} picks up, because the AWS SDK resolves the default profile itself. That matters beyond provider blocks — an unmanaged stack’s own backend "s3" {}, a terraform_remote_state data source, and any hook that shells out to the aws CLI all look for default unless told otherwise.

A profile you didn’t configure resolves to nothing. If your HCL names a profile with no matching role, the run fails with a credentials error rather than quietly falling back to the runner host’s own AWS identity.

That holds even for a stack with no AWS integration at all. GantryCD pins the run’s AWS environment either way, so a stack that used to reach AWS through the runner’s own credentials — an instance profile, or the operator’s ~/.aws on a local runner — now fails instead of silently deploying under an identity nobody chose. If a stack was relying on that, give it an integration. This is a breaking change, and a deliberate one: a run’s cloud access should come from what the stack was granted, not from where it happened to execute.

Role paths are supportedarn:aws:iam::123456789012:role/service-role/MyRole is a valid role ARN here.

Every role needs its own trust-policy entry naming this stack’s external ID. That repetition is the security property, not an oversight: the external ID embeds the stack, so pasting an arbitrary role ARN into a stack grants nothing unless that role already trusts GantryCD for that exact stack. Every role in the integration shares the same external ID — it pins the stack, not the role.

Do not wildcard the external ID. The external ID is realm@org@stack@mode, and on a shared GantryCD the realm is one platform-wide value — so the org component is the only thing separating you from every other tenant. Writing StringLike: "gantrycd@myorg@*" removes the per-stack pinning and lets any stack in your org assume the role; StringLike: "gantrycd@*" (or matching only the realm) hands it to every other customer on the platform. Match the full realm@yourorg@yourstack@mode, or at most realm@yourorg@yourstack@*.

Never point a stack at a role you did not create for it — least of all one of GantryCD’s own. A role that trusts the GantryCD backend without requiring the external ID (GantryCD’s platform roles, e.g. its Terraform-state role, are like this) can be assumed by any stack that names it, bypassing the pinning above entirely. This is why an operator must run GantryCD so its runtime principal cannot assume its own platform roles — see Cloud Integrations → the runtime principal must not be able to assume GantryCD’s own roles.

Scope a role per stack

By default a run gets a role’s full permissions. Optionally, each role can carry an inline session policy — a standard IAM policy JSON document — that GantryCD passes as the Policy on that role’s sts:AssumeRole call.

AWS mints credentials that are the intersection of the role’s own permissions and this policy, so the policy can only narrow the role, never expand it. This lets several stacks share one broad IAM role while each is scoped down to just what it needs (e.g. one stack limited to a single S3 bucket, another to EC2 in one region). It scopes only the role that declares it — the stack’s other roles are unaffected. Leave it blank to assume the role plainly.

Set it in the stack’s Cloud integrations (the “Inline session policy” field), or via the CLI, as a third key on the role:

gantrycli cloud-integrations create --org <org> --stack <stack> --provider aws \
  --aws-role name=default,role_arn=arn:aws:iam::123456789012:role/GantryShared,inline_policy=@scope.json

inline_policy accepts a JSON literal or @path/to/file.json, and must come last in the --aws-role value — a policy document is full of commas and =, so everything after inline_policy= is taken verbatim. The policy is validated as an IAM policy document and stored minified; the 2048-character session-policy limit is checked against that minified form (so generous indentation in your source file is fine). The web form pretty-prints it for editing and has a Format button. Because the shared role must be broad enough to cover every stack’s needs, keep least-privilege in the per-stack policy, not only in the role.

A malformed policy is caught when the run starts (STS rejects it), not at deployment creation — the pre-flight only probes the backend’s own principal.

How it behaves

  • At run start the backend calls sts:AssumeRole once per role, with the external ID and that role’s inline session policy if set, and writes the sessions into the run’s private AWS credentials file. Credentials are never put in the environment, so an env dump or set -x trace from a hook or raw run cannot leak them into the run log.
  • The deployment pre-flight runs sts:GetCallerIdentity against the backend’s own principal; if the backend can’t authenticate, deployment creation fails before any runner starts.
  • The session is named <org>-<stack>-<run>, e.g. acme-prod-vpc-networking-eu-west-1-a1b2c3d4, so your CloudTrail shows which stack made each call. The org is capped at 9 characters, the run is the first 8 of the run ID (enough to find it in GantryCD), and the stack takes the rest of AWS’s 64-character session-name limit — so a very long stack ID is truncated in the trail, never the org or run.
  • Credentials expire with the run (bounded by RUN_TTL and the role’s max session duration). If a role’s MaxSessionDuration is shorter than RUN_TTL + grace, the AssumeRole fails and the run fails at startup.

Security

The same credentials are used for every run mode — a plan run executes the stack’s (possibly PR-authored) OpenTofu with them reachable from the process. Treat every role a stack lists as reachable by anyone who can submit code to that stack. The external ID embeds the run mode (realm@org@stack@mode), so if you want read-only plans, gate write actions on that condition in the trust policy, or point the stack at a separate plan role.

Internals

Cloud Integrations covers the backend identity and the RUNTIME_AWS_* overrides; Runner Runtime Credentials covers delivery into the worker.