
Repointing a GitHub Actions pipeline from an S3 backend to Scalr looks like a ten-minute change. Swap the backend block, change a secret, push. The code part really is that small.
State is where it bites. On a laptop, terraform init notices you changed backends and offers -migrate-state to copy everything over. A GitHub Actions runner never gets that chance. Do the steps in the wrong order and your first plan against Scalr proposes recreating your entire infrastructure from scratch.
This post walks through the migration in the order that works: state first, code second. It's based on Scalr's migration guide, which includes the bulk migration script referenced below.
Backend migration in Terraform and OpenTofu depends on the local .terraform directory. That directory records which backend was configured the last time init ran, so when you edit the backend block and re-run init locally, the CLI can diff old against new and offer to move the state.
CI runners don't keep that directory. Every GitHub Actions job checks out the repo fresh, runs init with no memory of any previous backend, and initializes directly against whatever the current backend block says. If that's a brand-new Scalr workspace, init succeeds against an empty workspace, and plan then compares your configuration to nothing. Result: a plan that wants to create every resource you already have.
So the sequencing rule for any CI-driven backend migration is: move the state while the code still points at S3, then change the backend block and the workflow together.
Four things, and none of them touch your resource code:
| S3 backend | Scalr | |
|---|---|---|
| Backend block | backend "s3" |
backend "remote" |
| CI authentication | AWS access key pair | One Scalr API token |
| State locking | DynamoDB table or S3 lockfile | Built into Scalr |
| Where runs execute | GitHub-hosted runner | Still the runner (unchanged) |
The locking row deserves a footnote. DynamoDB was mandatory for S3 state locking for years, but as of July 2026 Terraform 1.11+ supports S3-native locking through use_lockfile, and HashiCorp has deprecated the DynamoDB path. OpenTofu supports use_lockfile too and says it has no plans to deprecate DynamoDB locking. Either way, after this migration the locking infrastructure goes away entirely, because Scalr locks state itself.
Before you start, you'll need a Scalr environment (note its env-... ID), a service account with a token rather than a personal one so CI has its own identity, that token stored as a SCALR_TOKEN secret in GitHub, and tofu (or terraform), the aws CLI, and jq installed wherever you'll run the migration script.
Scalr's guide ships a script that lists every state file in the bucket, creates a Scalr workspace for each, and imports the state into it. You configure four variables at the top: the bucket, its region, your Scalr hostname, and the environment ID.
Workspace names come from the S3 keys. A key like app/prod/terraform.tfstate becomes the workspace app-prod, with slashes converted to dashes. A root-level terraform.tfstate gets named after the bucket. Native S3 workspace paths like env:/prod/app/terraform.tfstate map to app-prod as well.
Run it with a dry run first:
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export SCALR_TOKEN=...
./migrate-state.sh --dry-run # print the key-to-workspace mapping
./migrate-state.sh # do the migrationRead the dry-run output line by line. The workspace names it prints are exactly the names you'll write into your backend blocks in the next step, and a surprise in the mapping is much cheaper to catch here than after the import.
The script is built to be re-run safely. It checks whether a target workspace already contains resources and skips it unless you set FORCE=1, it handles S3 pagination for buckets with thousands of state files, and an S3_PREFIX="path/" variable limits a run to one prefix if you'd rather migrate incrementally.
One operational rule: pause your pipelines while the migration runs. A CI job applying against S3 mid-migration creates a race between the old backend's lock and the state the script already copied.
Once the state is in Scalr, replace the S3 block with a remote block pointing at your account:
# Before
terraform {
backend "s3" {
bucket = "my-terraform-states"
key = "app/prod/terraform.tfstate"
region = "us-east-1"
}
}
# After
terraform {
backend "remote" {
hostname = "example.scalr.io"
organization = "env-xxxxxxxxxxxx"
workspaces {
name = "app-prod"
}
}
}Scalr's remote backend uses the environment ID as the organization value. The workspace name must match what the migration script created from that key, which is why the dry-run output matters.
Two edits. First, remove the AWS credentials step if those credentials existed only for the backend. Second, add the Scalr token as an environment variable.
The variable name follows the Terraform CLI credential convention: TF_TOKEN_ plus the backend hostname, with dots replaced by underscores and hyphens replaced by double underscores. For example.scalr.io, that's TF_TOKEN_example_scalr_io.
jobs:
tofu:
runs-on: ubuntu-latest
env:
TF_TOKEN_example_scalr_io: ${{ secrets.SCALR_TOKEN }}
steps:
- 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-approveA caveat the minimal example glosses over: if your configuration provisions AWS resources, the AWS provider still needs credentials. You're only removing the credentials the backend consumed. Most teams keep provider auth (ideally via OIDC rather than static keys) and drop just the state-access keys.
Get the variable name exactly right. A near-miss like TF_TOKEN_example_scalr-io fails authentication even though the secret behind it is perfectly valid, and the error won't tell you the name was the problem.
Push the backend change and the workflow change in one commit. The next pipeline run should initialize against Scalr and produce a plan with no changes, because the state it finds in the workspace matches the infrastructure that state always described.
Two other outcomes and what they mean. A plan that wants to create everything means the workspace the backend block names is empty: the state landed under a different workspace name, or didn't land at all. Check the dry-run mapping against your workspaces { name = ... } value. A plan with a handful of changes usually means pre-existing drift that S3 was silently carrying, not a migration failure. Look at whether the diffs are plausible for your environment before assuming the import went wrong.
And keep local hygiene: never commit a terraform.tfstate file that ended up on your machine during testing. Local state files in the repo are how teams end up with two sources of truth.
You can, and plenty of teams do. Scalr's remote backend works with CLI-driven workspaces, so GitHub Actions stays the orchestrator and nothing about your triggers or job structure changes. We have a separate walkthrough of that setup in Deploying your infrastructure with Scalr and GitHub Actions.
To be fair to the incumbent: if all you need is durable storage and locking, S3 is cheap and battle-tested, and with native lockfiles it no longer even needs DynamoDB. The case for moving state to Scalr is everything around the storage: state history and rollback, RBAC over who can read and write which workspace's state, and removing long-lived AWS keys from CI for state access. It also puts you one step from moving plan and apply execution into Scalr later, with PR-driven runs and policy checks, without another state migration.
If you're migrating a single workspace by hand rather than a bucket full of them, the simpler path in How To: Migrate Terraform State Into Scalr covers it. And if you're staying on S3 for now, our guide to the AWS S3 backend block covers the configuration in depth.
Start with the dry run on a single S3_PREFIX. Ten minutes of reading its output tells you whether the workspace mapping fits how your bucket is organized, and that's the only genuinely irreversible decision in the whole migration.
