Getting started
This guide takes one repository from nothing to a first stackorder apply. You deploy the server, create the GitHub App, create two kinds of AWS role, and add three files to the repository.
Before you start
You need:
- a GitHub organization where you can create and install GitHub Apps;
- an AWS account with an S3 bucket for Terraform or OpenTofu state;
- somewhere to run one container behind a public HTTPS URL, and a Postgres database;
- Terraform or OpenTofu 1.10 or later if you want S3-native state locking with
use_lockfile.
The examples use the organization acme, the repository acme/infra, AWS account 123456789012 and the server URL https://stackorder.example.com. The repository looks like this:
acme/infra
├── stackorder.yaml
├── .github/workflows/
│ ├── stackorder-plan.yml
│ └── stackorder-run.yml
├── modules/
│ └── vpc/
└── stacks/
├── prod/
│ ├── vpc/
│ └── apps/
│ └── .stackorder.yaml
└── staging/
└── vpc/Each stack directory has a backend "s3" block. That block is what makes a directory under stacks/** a stack, and it is where the CLI reads the state location from.
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "stacks/prod/vpc/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}1. Deploy the server
The server is one container and one Postgres database. It listens on port 8080; put TLS in front of it with a load balancer or a reverse proxy. GitHub must be able to reach https://stackorder.example.com/webhooks/github.
Generate a session key once and keep it. It signs session cookies, so changing it signs everyone out.
openssl rand -hex 32 > session.keyOption A: Docker
docker run -d --name stackorder -p 8080:8080 \
-e DATABASE_URL='postgres://stackorder:change-me@db.internal:5432/stackorder?sslmode=require' \
-e STACKORDER_BASE_URL='https://stackorder.example.com' \
-e STACKORDER_SESSION_KEY="$(cat session.key)" \
ghcr.io/stackorder/stackorder:latestThe server refuses to start without DATABASE_URL and STACKORDER_BASE_URL. It runs its database migrations at start-up. Without the GitHub App variables it starts in setup mode and serves only /setup, /healthz and /readyz.
curl -fsS https://stackorder.example.com/readyzDeploy as a container covers Compose, other platforms and Postgres requirements.
Option B: the Terraform module
The repository ships a Terraform module in deploy/terraform that creates an ECS Fargate service behind an ALB with an ACM certificate, an RDS Postgres instance, and the secrets wiring. See Deploy on AWS.
2. Create the GitHub App
Open https://stackorder.example.com/setup in a browser.
The page renders a GitHub App manifest with the webhook URL, permissions and events already filled in, and posts it to GitHub. Confirm the App on GitHub. GitHub returns the App id, private key, webhook secret and OAuth client id and secret in one exchange, and the page prints them once as environment variables:
| Variable | Holds |
|---|---|
GITHUB_APP_ID | The App id |
GITHUB_APP_PRIVATE_KEY | The App private key (PEM) |
GITHUB_WEBHOOK_SECRET | The secret GitHub signs webhooks with |
GITHUB_OAUTH_CLIENT_ID | The client id for human sign-in |
GITHUB_OAUTH_CLIENT_SECRET | The client secret for human sign-in |
Store them in your secret store straight away; the page does not show them again. Then restart the server with them. With Docker, keep the single-line values in an env file and pass the key separately:
docker rm -f stackorder
docker run -d --name stackorder -p 8080:8080 \
--env-file stackorder.env \
-e GITHUB_APP_PRIVATE_KEY="$(cat stackorder-app.pem)" \
ghcr.io/stackorder/stackorder:latestHere stackorder.env holds DATABASE_URL, STACKORDER_BASE_URL, STACKORDER_SESSION_KEY, GITHUB_APP_ID, GITHUB_WEBHOOK_SECRET, GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET.
For GitHub Enterprise Server, set GITHUB_API_URL, GITHUB_WEB_URL and GITHUB_OIDC_ISSUER before opening /setup: the page sends the manifest to GITHUB_WEB_URL, which is github.com unless you set it. The flow is otherwise the same. See Server configuration.
3. Install the App
On GitHub, open the App's settings page and choose Install App. Install it on the acme organization and select:
- the repositories that hold stacks, such as
acme/infra; - repositories that hold shared git modules, so the server records their version tags;
- repositories named by cross-repo
depends_onentries.
Then sign in at https://stackorder.example.com with GitHub. The UI shows the repositories of the organization where the App is installed.
4. Create the AWS roles
Stackorder never holds AWS credentials. Each job assumes a role with its own GitHub OIDC token. You create a plan role, for pull request plans and for the plans and drift checks the server dispatches, and one apply role per environment.
If the account has no GitHub OIDC provider yet, create it:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.comThe plan role
Trusted by two kinds of job of acme/infra: pull request plan jobs, whose token subject is repo:acme/infra:pull_request, and the plan and drift jobs the server dispatches to stackorder-run.yml, which always run under the environment default and so carry repo:acme/infra:environment:default. Give it read access to state and the read-only permissions your providers need to plan. plan takes the state lock by default, so it also needs to write the lock: the <key>.tflock object with use_lockfile, or the DynamoDB table.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": [
"repo:acme/infra:pull_request",
"repo:acme/infra:environment:default"
]
}
}
}
]
}The apply role
Trusted only by jobs of acme/infra that run under the production GitHub environment. GitHub puts environment:production in the token's subject only when the job actually ran under that environment, which means after its protection rules passed. Give this role the write permissions your stacks need.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/infra:environment:production"
}
}
}
]
}Create a second apply role for staging the same way, with environment:staging. Only apply jobs of stackorder-run.yml use these roles, through aws-role-arn-map; plans requested with a stackorder plan comment and scheduled drift checks use the plan role, passed as aws-plan-role-arn.
To pin the roles to the canonical reusable workflow as well, see the AWS trust policy layer.
5. Create the GitHub environments
In the repository settings, under Environments, create production:
- Required reviewers: a team, such as
acme/platform-prod. - Prevent self-review: on, so the requester cannot approve their own deployment.
- Deployment branches: the default branch only. Server-dispatched runs start from the default branch and check out the commit they are given.
Create staging the same way, with fewer or no reviewers. The environment default, which GitHub creates on first use with no protection rules, is where the server's plan and drift dispatches run, and where stacks without an instance that match no prefix apply. Give it no reviewers, or every stackorder plan comment waits for an approval.
Required reviewers on private repositories need GitHub Enterprise. Environments and authorization covers the alternatives.
6. Add stackorder.yaml
At the repository root:
version: 1
tool: tofu
tool_version: "1.10.0"
environments:
"stacks/prod/": production
"stacks/staging/": staging
apply:
require_approvals: 1Everything else keeps its default: stacks under stacks/**, modules/** treated as modules rather than stacks, applies before merge, dependents propagated. The full list is on the stackorder.yaml page.
Declare dependencies between stacks in the dependent stack's directory. In stacks/prod/apps/.stackorder.yaml:
depends_on:
- stacks/prod/vpc7. Add the workflow files
.github/workflows/stackorder-plan.yml runs on every pull request push:
name: stackorder plan
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: stackorder-plan-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
plan:
permissions:
id-token: write
contents: read
actions: read
checks: write
pull-requests: read
uses: stackorder/actions/.github/workflows/plan.yml@v1
with:
server-url: ${{ vars.STACKORDER_SERVER_URL }}
aws-role-arn: arn:aws:iam::123456789012:role/stackorder-plan
tool: tofu
secrets: inherit.github/workflows/stackorder-run.yml is dispatched by the server only:
name: stackorder run
run-name: stackorder ${{ inputs.mode }} ${{ inputs.run_id }} wave ${{ inputs.wave }}
on:
workflow_dispatch:
inputs:
run_id: { type: string, required: true }
mode: { type: string, required: true }
wave: { type: string, required: false }
sha: { type: string, required: false }
stacks: { type: string, required: true }
jobs:
run:
permissions:
id-token: write
contents: read
actions: read
checks: write
uses: stackorder/actions/.github/workflows/run.yml@v1
with:
server-url: ${{ vars.STACKORDER_SERVER_URL }}
run-id: ${{ inputs.run_id }}
mode: ${{ inputs.mode }}
wave: ${{ inputs.wave }}
sha: ${{ inputs.sha }}
stacks: ${{ inputs.stacks }}
aws-plan-role-arn: arn:aws:iam::123456789012:role/stackorder-plan
aws-role-arn-map: '{"stacks/prod/": "arn:aws:iam::123456789012:role/stackorder-apply-prod", "stacks/staging/": "arn:aws:iam::123456789012:role/stackorder-apply-staging"}'
secrets: inheritThe run-name line lets the server recognise the workflow runs it dispatched, and the five inputs must all be declared, because the server sends all five. The permissions blocks matter: a called workflow can only narrow the permissions its caller grants. Workflows explains each input and permission.
Both reusable workflows require server-url, the server's base URL. The files above read it from the Actions variable STACKORDER_SERVER_URL; if it is empty, the CLI runs in local mode and every check ends up unconfirmed. Set it for the organization or the repository:
gh variable set STACKORDER_SERVER_URL --org acme --visibility all \
--body https://stackorder.example.com8. Protect the default branch
Add a branch protection rule or ruleset on the default branch that requires:
- the status checks
stackorder/planandstackorder/apply(inon_mergemode, onlystackorder/plan, since the apply runs after the merge); - at least the number of approvals set in
apply.require_approvals; - review from code owners, if you use
CODEOWNERS.
Stackorder only reports checks. GitHub enforces the merge.
9. Open a pull request
Change a file in stacks/prod/vpc and open a pull request. Once the plan workflow finishes you see:
- a
stackorder/resolvecheck listing the affected stacks and their waves; - one check per stack,
stackorder/plan: stacks/prod/vpcandstackorder/plan: stacks/prod/apps, the second one because it depends on the first; - the roll-up check
stackorder/plan; - one sticky comment with a collapsible plan summary per stack.
Every new push re-plans and replaces the previous results.
10. Apply
Approve the pull request, then comment:
stackorder applyThe App adds an eyes reaction when it receives the command and a rocket reaction when it dispatches, so a dropped command is visible. If the apply gate refuses, a comment names the failing check and the reason.
Otherwise the server locks both stacks and dispatches wave 0, stacks/prod/vpc, to stackorder-run.yml under the production environment. Approve the deployment in the Actions UI; the sticky comment links straight to it. When wave 0 is green the server dispatches wave 1, stacks/prod/apps, which asks for approval again. A green last wave turns stackorder/apply green.
Merge the pull request. The server releases the locks.
Try the CLI locally
The same CLI runs on a laptop. Download a release for your platform and verify it against the checksum file:
gh release download --repo stackorder/stackorder \
--pattern 'stackorder_*_linux_amd64.tar.gz' \
--pattern 'stackorder_*_checksums.txt'
sha256sum --check --ignore-missing stackorder_*_checksums.txt
tar -xzf stackorder_*_linux_amd64.tar.gz stackorderRelease archives exist for linux, darwin and windows (as .zip) on amd64 and arm64. Then, from the repository root:
stackorder affected --base main
stackorder graph --format dot | dot -Tsvg > graph.svgThe local demo runs the whole thing on one machine, with Postgres and LocalStack in Docker and the example repository.
Next steps
- Concepts: edges, the affected set, waves and locks in detail.
- Environments and authorization: the five layers that gate an apply.
- Drift detection: scheduled plans on the default branch.
- Troubleshooting: what to do when a check says
unconfirmed.