TrademarkTrademark
Features
Documentation

Migrating GitHub Actions from an S3 Backend to Scalr

How to migrate Terraform state from an S3 backend to Scalr when GitHub Actions runs your pipeline: bulk state migration, backend block changes, and workflow updates, in the order that avoids recreating your infrastructure.
Sebastian StadilJuly 15, 2026
Migrating GitHub Actions from an S3 Backend to Scalr
Key takeaways
  • Migrate state first, while your code still points at S3. If you swap the backend block before the state exists in Scalr, your first plan will try to recreate every resource.
  • GitHub Actions runners are ephemeral, so terraform init never sees the old backend and can't offer -migrate-state the way it does on a laptop. The state has to move out of band.
  • Scalr's migration script walks the S3 bucket, creates one workspace per state file (app/prod/terraform.tfstate becomes workspace app-prod), and skips workspaces that already contain resources.
  • The workflow changes are small: drop the AWS credentials step the backend needed and add a TF_TOKEN_<hostname> environment variable, with dots replaced by underscores.
  • The success test is a first plan that reports no changes. A plan that wants to create everything means the state never landed in the workspace the backend block points at.

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.

Why can't GitHub Actions migrate Terraform state on its own?

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.

What actually changes when you move from S3 to Scalr?

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.

How do you bulk-migrate state files from S3 into Scalr?

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 migration

Read 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.

How do you update the Terraform backend block?

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.

How do you update the GitHub Actions workflow?

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

A 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.

How do you know the migration worked?

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.

Should you keep running Terraform in GitHub Actions after migrating?

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.

Frequently asked questions

How do I migrate Terraform state from S3 to Scalr?

Migrate the state before touching your code. Scalr publishes a migration script that lists every state file in the S3 bucket, creates a matching Scalr workspace for each, and imports the state into it. Once the state is in Scalr, replace the backend "s3" block with a backend "remote" block pointing at your Scalr hostname and environment, and update CI credentials.

Why does my first Terraform plan want to recreate all resources after changing backends?

Because the new backend has no state. Terraform compares your configuration against the state file in the configured backend, and an empty workspace looks like brand-new infrastructure. This is the standard failure mode when a CI pipeline's backend is changed without migrating state first, since ephemeral runners can't run terraform init -migrate-state interactively.

Do I still need DynamoDB after moving from an S3 backend to Scalr?

No. DynamoDB's only role in a Terraform S3 backend is state locking, and Scalr locks state natively. As of July 2026, Terraform 1.11+ also offers S3-native locking via use_lockfile (with DynamoDB locking deprecated), so even teams staying on S3 are moving off DynamoDB.

Can I keep running Terraform in GitHub Actions after migrating state to Scalr?

Yes. Scalr's remote backend works with CLI-driven workspaces, so GitHub Actions keeps running init, plan, and apply exactly as before. The only workflow changes are authentication: a Scalr token instead of the AWS credentials the S3 backend required. You can move plan and apply execution into Scalr later, but the state migration doesn't force it.

What does the TF_TOKEN environment variable naming convention look like?

Terraform and OpenTofu read credentials from environment variables named TF_TOKEN_ followed by the backend hostname, with dots replaced by underscores and hyphens replaced by double underscores. For a backend at example.scalr.io, the variable is TF_TOKEN_example_scalr_io. If the name doesn't match the hostname exactly, authentication fails even when the token itself is valid.
About the author
Sebastian StadilCEO at Scalr
Sebastian Stadil is the CEO at Scalr. He has over 15 years of devops experience, and started his career with AWS in 2004.
Part of
Thumbnail of CI/CD and GitOps for Terraform & OpenTofu

CI/CD and GitOps for Terraform & OpenTofu

Comprehensive guide to building reliable CI/CD pipelines and implementing GitOps workflows for Terraform and OpenTofu infrastructure automation.
Sebastian Stadil
March 31, 2026