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 blockbackend "s3" { ... }backend "remote" { ... }
AuthAWS access key/secretOne Scalr API token
State lockingNeeds a DynamoDB tableBuilt in
Where plan/apply runsOn the GitHub-hosted runnerOn Scalr (output streamed back to the CLI)
WorkflowAWS credentials step + tofu stepsSame 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_TOKEN added as a GitHub Actions secret, alongside whatever AWS credential secrets you already have for the S3 backend.
  • tofu, the aws CLI, and jq installed 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:

  1. Simple key → directory name. The key's directory becomes the workspace name, nested paths collapsing slashes to dashes:

    S3 keyScalr workspace
    networking/terraform.tfstatenetworking
    app/prod/terraform.tfstateapp-prod
    teams/payments/prod/db/terraform.tfstateteams-payments-prod-db
    terraform.tfstate (no directory)the bucket name
  2. 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 under env:/<workspace>/<key> (env: is the default workspace_key_prefix). The script recognizes this and folds the workspace name in as a suffix instead of keeping a literal env: segment, which wouldn't be a usable workspace name on its own:

    S3 keyScalr workspace
    app/terraform.tfstateapp (the default workspace)
    env:/prod/app/terraform.tfstateapp-prod
    env:/staging/app/terraform.tfstateapp-staging

    Run with --dry-run first 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-xxxxxxxxxxxxxxxxx

Sample 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 migrate

Stop 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-approve

Step 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.sh narrows the script to keys under that prefix.
  • Always run --dry-run before 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 under env:/) 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=1 if you specifically want to overwrite one.
  • TF_TOKEN_<hostname> must match your backend's hostname value exactly, or OpenTofu reports "Required token could not be found" even when the secret itself is set correctly.
  • Don't run tofu state pull and commit a local terraform.tfstate file as part of your working directory. OpenTofu expects a clean separation between local files and remote state.