Migrating GitHub Actions from an S3 Backend to Scalr
This guide walks through moving a GitHub Actions + OpenTofu (or Terraform) pipeline off an S3 backend and onto Scalr. The order matters: you migrate the state first, while your code still points at S3, and only then cut the code over to the new backend. Doing it in that order means the first CI run against Scalr sees state that's already there.
What's changing
| Before (S3) | After (Scalr) | |
|---|---|---|
| Backend block | backend "s3" { ... } | backend "remote" { ... } |
| Auth | AWS access key/secret | One Scalr API token |
| State locking | Needs a DynamoDB table | Built in |
Where plan/apply runs | On the GitHub-hosted runner | On Scalr (output streamed back to the CLI) |
| Workflow | AWS credentials step + tofu steps | Same tofu steps, no AWS credentials step |
Prerequisites
- A Scalr environment (note its
env-...ID ). - A Scalr Service Account and Service Account Token for CI, rather than a personal token, so the pipeline's identity isn't tied to one person.
SCALR_TOKENadded as a GitHub Actions secret, alongside whatever AWS credential secrets you already have for the S3 backend.tofu, theawsCLI, andjqinstalled wherever you run the migration script (locally is simplest). On macOS:brew install opentofu awscli jq.
Step 1 — Migrate the state
This step will create the workspace in Scalr and push the state file into it.
Do this before touching any .tf files. The script below finds every */terraform.tfstate object in your S3 bucket, pulls each one, and pushes it into a matching Scalr workspace that is named after the key.
It uses an explicit pull-then-push rather than relying on OpenTofu's built-in "backend changed, copy state?" prompt. That built-in path only works when a single working directory transitions from the old backend to the new one without losing its local .terraform/ cache in between, which is fine for a one-off local run, but not something you can depend on if any part of this happens through CI or a fresh checkout. Pull-then-push has no such dependency: it's just a state file handed from one init to another.
How S3 keys map to Scalr workspace names
Real S3 backends rarely hold just one state file at a flat key, so the script handles a few layouts automatically, in this order:
-
Simple key → directory name. The key's directory becomes the workspace name, nested paths collapsing slashes to dashes:
S3 key Scalr workspace networking/terraform.tfstatenetworkingapp/prod/terraform.tfstateapp-prodteams/payments/prod/db/terraform.tfstateteams-payments-prod-dbterraform.tfstate(no directory)the bucket name -
Terraform's native S3-backend workspaces. If a team used
terraform workspace new <name>against the S3 backend rather than separate keys per environment, non-default workspaces are stored underenv:/<workspace>/<key>(env:is the defaultworkspace_key_prefix). The script recognizes this and folds the workspace name in as a suffix instead of keeping a literalenv:segment, which wouldn't be a usable workspace name on its own:S3 key Scalr workspace app/terraform.tfstateapp(the default workspace)env:/prod/app/terraform.tfstateapp-prodenv:/staging/app/terraform.tfstateapp-stagingRun with
--dry-runfirst if any part of your bucket might use this pattern as it's easy to miss when you didn't set the backend up yourself.
Safe to re-run
Before migrating each key, the script checks whether the target Scalr workspace already exists and already has resources in its state. If so, it skips that key instead of overwriting it. That matters because real migrations rarely go top to bottom in one clean pass — a run gets interrupted, a handful of workspaces get migrated ahead of the rest, or you just want to re-run the script after fixing something without redoing everything that already succeeded. Set FORCE=1 if you specifically want to overwrite a workspace that already has state.
Make sure to update the following lines in the script:
S3_BUCKET="<your-s3-bucket>"
S3_REGION="<your-s3-region>"
S3_PREFIX="${S3_PREFIX:-}" # optional, narrows the search
WORKSPACE_MAP_FILE="${WORKSPACE_MAP_FILE:-}" # optional, see mapping rules above
SCALR_HOSTNAME="<your-account>.scalr.io"
SCALR_ORG="<your-scalr-environment-id>" # e.g. env-xxxxxxxxxxxxxxxxxSample Script:
#!/usr/bin/env bash
# Bulk migration: find every OpenTofu/Terraform state file in an S3
# bucket and push each one into its own Scalr workspace. See "How S3
# keys map to Scalr workspace names" above for the naming rules.
#
# --- Pagination ---
# `aws s3api list-objects-v2` returns up to 1000 keys per page, but the
# AWS CLI auto-paginates by default (it makes as many follow-up calls as
# needed and aggregates the results before applying --query) as long as
# you don't pass --no-paginate or --max-items. This script relies on
# that default, so buckets with thousands of state files are handled the
# same as buckets with one.
#
# Requires: tofu, aws CLI, jq
# macOS: brew install awscli jq (tofu: brew install opentofu)
# Linux: use your package manager, or see opentofu.org/docs/intro/install
#
# Note for macOS: this avoids bash 4+ builtins (like mapfile) on purpose,
# since macOS ships bash 3.2 by default at /bin/bash and doesn't update it.
#
# Usage:
# export AWS_ACCESS_KEY_ID=...
# export AWS_SECRET_ACCESS_KEY=...
# export SCALR_TOKEN=...
# ./migrate-state.sh # migrate everything found
# ./migrate-state.sh --dry-run # just list what would be migrated
# S3_PREFIX="app/" ./migrate-state.sh # only keys under a prefix
# WORKSPACE_MAP_FILE=map.tsv ./migrate-state.sh # override naming for specific keys
# FORCE=1 ./migrate-state.sh # overwrite workspaces that already have state
set -euo pipefail
for cmd in tofu aws jq; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Missing dependency: $cmd"
case "$cmd" in
tofu) echo " Install with: brew install opentofu (or see opentofu.org/docs/intro/install)" ;;
aws) echo " Install with: brew install awscli (or see docs.aws.amazon.com/cli)" ;;
jq) echo " Install with: brew install jq" ;;
esac
exit 1
fi
done
S3_BUCKET="<your-s3-bucket>"
S3_REGION="<your-s3-region>"
S3_PREFIX="${S3_PREFIX:-}" # optional, narrows the search
WORKSPACE_MAP_FILE="${WORKSPACE_MAP_FILE:-}" # optional, see mapping rules above
SCALR_HOSTNAME="<your-account>.scalr.io"
SCALR_ORG="<your-scalr-environment-id>" # e.g. env-xxxxxxxxxxxxxxxxx
DRY_RUN=false
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN=true
fi
: "${SCALR_TOKEN:?SCALR_TOKEN must be set}"
tf_token_var_name() {
local host="$1"
host="${host//-/__}"
host="${host//./_}"
echo "TF_TOKEN_${host}"
}
export "$(tf_token_var_name "$SCALR_HOSTNAME")=$SCALR_TOKEN"
# Looks up an exact-match override for $1 in WORKSPACE_MAP_FILE, if set.
# Prints nothing (and returns non-zero) when there's no override, so
# callers fall through to the automatic naming rules.
lookup_override() {
local key="$1"
[[ -n "$WORKSPACE_MAP_FILE" && -f "$WORKSPACE_MAP_FILE" ]] || return 1
local match
match="$(awk -F'\t' -v k="$key" '$1 == k { print $2; exit }' "$WORKSPACE_MAP_FILE")"
[[ -n "$match" ]] || return 1
echo "$match"
}
derive_workspace_name() {
local key="$1"
local override
if override="$(lookup_override "$key")"; then
echo "$override"
return
fi
local dir
dir="$(dirname "$key")"
# Native S3-backend workspaces live under "env:/<workspace>/<rest>".
# Fold the workspace name in as a suffix instead of keeping the
# literal "env:" segment.
if [[ "$dir" == env:/* ]]; then
local rest="${dir#env:/}" # "<workspace>[/<rest-of-path>]"
local native_ws="${rest%%/*}"
local base_dir="${rest#*/}"
if [[ "$base_dir" == "$rest" ]]; then
dir="$native_ws"
else
dir="${base_dir//\//-}-${native_ws}"
fi
echo "$dir"
return
fi
if [[ "$dir" == "." ]]; then
echo "$S3_BUCKET"
else
echo "${dir//\//-}"
fi
}
migrate_one() {
local key="$1"
local workspace
workspace="$(derive_workspace_name "$key")"
echo "==> $key -> workspace '$workspace'"
# --- Check first: does this workspace already have migrated state? ---
# `tofu init` against Scalr auto-creates the workspace if it doesn't
# exist yet, which is harmless - an empty workspace is exactly what we
# want to find at this point.
local scalr_dir
scalr_dir=$(mktemp -d)
cat > "$scalr_dir/main.tf" <<EOF
terraform {
backend "remote" {
hostname = "$SCALR_HOSTNAME"
organization = "$SCALR_ORG"
workspaces {
name = "$workspace"
}
}
}
EOF
(cd "$scalr_dir" && tofu init -reconfigure -input=false >/dev/null)
local existing_state existing_count
existing_state="$(cd "$scalr_dir" && tofu state pull 2>/dev/null)"
existing_count="$(echo "$existing_state" | jq '.resources | length' 2>/dev/null)"
existing_count="${existing_count:-0}"
if [[ "$existing_count" -gt 0 && "${FORCE:-0}" != "1" ]]; then
echo " workspace '$workspace' already has $existing_count resource(s) - skipping (set FORCE=1 to overwrite)"
rm -rf "$scalr_dir"
return
fi
if [[ "$existing_count" -gt 0 ]]; then
echo " workspace '$workspace' already has $existing_count resource(s) - FORCE=1 set, overwriting"
fi
# --- Pull from S3 (separate directory - different backend type) ---
local s3_dir
s3_dir=$(mktemp -d)
cat > "$s3_dir/main.tf" <<EOF
terraform {
backend "s3" {
bucket = "$S3_BUCKET"
key = "$key"
region = "$S3_REGION"
}
}
EOF
(
cd "$s3_dir"
tofu init -input=false >/dev/null
tofu state pull > state.json
)
echo " pulled $(wc -c < "$s3_dir/state.json") bytes from S3"
# --- Push into the already-initialized Scalr workspace ---
cp "$s3_dir/state.json" "$scalr_dir/state.json"
(cd "$scalr_dir" && tofu state push state.json)
echo " pushed into Scalr workspace '$workspace'"
rm -rf "$scalr_dir" "$s3_dir"
}
echo "Listing state files in s3://$S3_BUCKET${S3_PREFIX:+/$S3_PREFIX} ..."
# Plain while-read loop rather than `mapfile`/`readarray` - those are
# bash 4+ builtins and macOS's default /bin/bash is 3.2. The AWS CLI
# auto-paginates this call by default, so this also covers buckets with
# more than 1000 matching objects.
STATE_KEYS=()
while IFS= read -r key; do
[[ -n "$key" ]] && STATE_KEYS+=("$key")
done < <(
aws s3api list-objects-v2 \
--bucket "$S3_BUCKET" \
${S3_PREFIX:+--prefix "$S3_PREFIX"} \
--query "Contents[?ends_with(Key, 'terraform.tfstate')].Key" \
--output json | jq -r '.[]'
)
if [[ ${#STATE_KEYS[@]} -eq 0 ]]; then
echo "No terraform.tfstate objects found."
exit 1
fi
echo "Found ${#STATE_KEYS[@]} state file(s):"
for key in "${STATE_KEYS[@]}"; do
echo " $key -> workspace '$(derive_workspace_name "$key")'"
done
echo
if $DRY_RUN; then
echo "Dry run - nothing migrated."
exit 0
fi
for key in "${STATE_KEYS[@]}"; do
migrate_one "$key"
done
echo
echo "Done. Migrated ${#STATE_KEYS[@]} workspace(s) into Scalr."Run it dry first, then for real:
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export SCALR_TOKEN=...
./migrate-state.sh --dry-run # confirm the list of keys/workspaces looks right
./migrate-state.sh # actually migrateStop any other pipeline that might run apply against the S3 backend while you do this, as the S3 state locking and this pull don't coordinate with each other, so a concurrent apply could race the migration.
At this point Scalr already holds the real state for each workspace, and S3 is now just a read-only leftover copy. Your .tf files still say backend "s3" and CI is still green against it so nothing about your pipeline has changed yet.
Step 2 — Update the backend block
This is the only code change. Everything else in the file (resources, variables, outputs) stays exactly as it was.
# Before
terraform {
backend "s3" {
bucket = "<your-s3-bucket>"
key = "prod/terraform.tfstate"
region = "<your-s3-region>"
}
}# After
terraform {
backend "remote" {
hostname = "<your-account>.scalr.io"
organization = "<your-scalr-environment-id>" # env-xxxxxxxxxxxxxxxxx
workspaces {
name = "prod" # matches the S3 key's directory - see Step 1
}
}
}Step 3 — Update the GitHub Actions workflow
Two changes: drop the AWS credentials step (no longer needed for backend auth), and add a TF_TOKEN_<hostname> variable so OpenTofu authenticates to Scalr non-interactively. Dots in the hostname become underscores; hyphens become double underscores — my-account.scalr.io becomes TF_TOKEN_my__account_scalr_io.
# Before
name: OpenTofu (S3 backend)
on:
push:
branches: [main]
pull_request:
jobs:
tofu:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Setup OpenTofu
uses: opentofu/setup-opentofu@v1
- name: OpenTofu Init
run: tofu init
- name: OpenTofu Plan
run: tofu plan
- name: OpenTofu Apply
if: github.ref == 'refs/heads/main'
run: tofu apply -auto-approve# After
name: OpenTofu (Scalr backend)
on:
push:
branches: [main]
pull_request:
jobs:
tofu:
runs-on: ubuntu-latest
env:
TF_TOKEN_your_account_scalr_io: ${{ secrets.SCALR_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup OpenTofu
uses: opentofu/setup-opentofu@v1
- name: OpenTofu Init
run: tofu init
- name: OpenTofu Plan
run: tofu plan
- name: OpenTofu Apply
if: github.ref == 'refs/heads/main'
run: tofu apply -auto-approveStep 4 — Commit, push, verify
Push both changes together. The next pipeline run plans and applies against Scalr instead of S3 with the same triggers, same approval flow. Because the state was already moved in Step 1, this first run should report no changes (assuming nothing drifted between the migration and now) .
Notes
- If you're migrating a workspace at a time rather than the whole bucket,
S3_PREFIX="path/" ./migrate-state.shnarrows the script to keys under that prefix. - Always run
--dry-runbefore the real thing, especially on a bucket you didn't set up yourself — the printed key → workspace list is your chance to catch a naming collision or an unrecognized layout (like native S3-backend workspaces underenv:/) before anything moves. - The script skips any workspace that already has resources in its Scalr state, so it's safe to re-run after an interruption or partial migration. Use
FORCE=1if you specifically want to overwrite one. TF_TOKEN_<hostname>must match your backend'shostnamevalue exactly, or OpenTofu reports "Required token could not be found" even when the secret itself is set correctly.- Don't run
tofu state pulland commit a localterraform.tfstatefile as part of your working directory. OpenTofu expects a clean separation between local files and remote state.
