
As you may have seen, we just launched our public beta of envzero last month. As part of the run-up to the launch, our dev team had to go through and make sure everything about our infrastructure was ready for ongoing public use: one element being creating a maintenance mode for both our Application and our public API.
Why do you need a maintenance mode for something like env0, which is built to be highly available? Well whether it’s human error (as in the case of AWS S3), a DDOS attack (like with github), or just a major upgrade (like Zapier), even the most highly available applications from the most experienced providers sometimes need to be able to be taken offline for a short period of time. And when you have to do that, you want to make sure that your users understand what is happening and still have a good experience.
Here’s how our team put together our maintenance mode using Terraform, AWS, and Github Pages (including all the code at the end!)
Architecture
We host all of our infrastructure on AWS, with a clear separation between Application and API:
- React front end application is hosted on S3 with CloudFront as a CDN.
- The Backend services are mostly Serverless using AWS Lambda with API Gateway that manages our public API.
- DNS is managed by Route 53.
Since we have a separation between the frontend application and our API, we need to have a maintenance mode for each of them, especially as we also have a public API that is used by our customers for integration in their CI/CD pipelines and other tools. Don’t forget this if you have multiple ways in which people access your services!
.png)
Requirements
As with any project, the first step is to lay out requirements and constraints. In this case, we came up with the following list of what the solution should do:
- Be implemented with Infrastructure as Code (IaC) as all of our stack is based on IaC
- Switch back and forth as fast as possible between normal and maintenance mode
- Switch automatically between normal and maintenance mode
- Be hosted by separate providers, where possible, from the current infrastructure in order to provide maximum redundancy
- Have an internal backdoor to allow our team to deploy a fix and test it before switching back
- Not override the current configuration with new deployments
- Communicate to an active user that we are in maintenance mode
Implementing a Solution
After investigating, we came to a few conclusions:
- We wanted to have a simple place to store the maintenance mode html, so we went with github pages.
- The switch itself will be done through the DNS, pointing to github pages when we are in maintenance mode.
- We will create a new DNS record and a Cloudfront distribution that will be our backdoor, and it will be always up and running.
- We will use our current push mechanism to notify existing users that are in the app that we are currently in maintenance mode.
Based on that, we implemented the following at each piece of the system — each system contains a description of what we’re doing, along with the link to a gist with the actual Terraform code.
Cloudfront
Looking at the Cloudfront distribution code, you can see that we are creating 2 distributions, one is for the actual application and the other one is for a backdoor:
Route53
Also in AWS, we want to ensure that Route53 is pointing in the right direction. In case we are not in maintenance mode it should point to the Cloudfront distribution, and when in maintenance mode it should point our CNAME to the github page. In either case, we should have the backdoor pointing to the Cloudfront distribution.
Additionally, we also set the TTL to be 60 seconds so it won’t take too long to move back and forth between maintenance mode and regular operation:
⚠️ Pay attention that this Terraform code does not create the Route53 hosted zone, nor the SSL certificates — you need to complete those as appropriate for your own setup ⚠️
Git Repo
Next, we need a git repo containing the html files for the new maintenance mode site. So we’ve created a simple Terraform code that will create the repo as well as add all existing files in the “maintenance_mode_website” folder to the repo, which in our case is the maintenance mode html file:
Github Pages
The last part is the trickiest to implement in Terraform, because Github pages configuration is not actually part of the github Terraform provider, which means that I can’t really configure it with the Terraform code. However because github offers an API to configure github pages, it can be done programmatically via the API. In our case, we can use the envzero custom flows feature to trigger those API calls once the deploy is finished:
Deployment
Now that our system is all configured, all I have to do is change the Terraform variable of the maintenance mode to be true/false and deploy the environment (in our case via the envzero UI).

Conclusion
The complete template source code can be found in this github repo which includes all the Terraform code, scripts, our env0.yml and the maintenance page html file. We hope you find this useful, or get other ideas for more ways to use Terraform for your deployment workflows. Our next blog post will give you a sneak preview on how we are creating a maintenance mode on our API using Terraform.
About env0
envzero lets your team manage their own environments in AWS, Azure and Google, governed by your policies and with complete visibility & cost management. You can learn more about env0 here and you can also try it out yourself. Feel free to drop us your thoughts below!
Related Content

A Terraform string is just a sequence of characters, but almost everything you write in HCL touches one: resource names, tags, file paths, generated policies, connection strings. Knowing how Terraform builds, combines, and templates strings will save you from some of the more confusing errors you'll hit in a growing configuration.
This guide covers how strings are defined, how interpolation and template directives work, the two multiline syntaxes, and where to go for a deeper look at specific built-in functions.
What is a string in Terraform?
Terraform gives you two ways to write a string literal:
- Quoted strings – text wrapped in double quotes, e.g.
"us-east-1" - Heredoc strings – a multiline block bounded by a marker of your choosing, covered below
Quoted strings support a set of backslash escape sequences:
Two additional escapes don't use a backslash at all, and matter once you start interpolating: $${ produces a literal ${ instead of starting an interpolation, and %%{ produces a literal %{ instead of starting a directive. You'll want the first one any time a string needs to contain a literal dollar-brace sequence, for example when generating a shell script that itself uses variable expansion.
String interpolation in Terraform
Interpolation is how you drop a dynamic value into a string using ${ ... }. Terraform evaluates whatever is between the braces and converts it to a string if needed.
The expression inside ${ } can be a variable reference, an attribute reference, a function call, or a simple arithmetic expression:
If you need a literal ${var.name} in your output, rather than an interpolation, escape it with an extra dollar sign: $${var.name}.
env zero note: env zero's Environment Outputs feature borrows this same pattern to reference a value from a different environment: ${env0::}. Add a variable of type Environment Output, point it at another environment's output name, and env zero resolves the value at run time, no manual copy-paste or API calls between environments required. Right now only string-type outputs are supported, which lines up neatly with everything else in this guide, if the value you need is a list or map, you'll need to reference it a different way.Template directives: conditionals and loops inside a string
Interpolation isn't the only template sequence Terraform supports. A %{ ... } sequence is a directive, and it lets you branch or iterate inside a string, something most string-focused guides skip entirely.
The if / else / endif directive chooses between two outputs based on a boolean expression:
The for / endfor directive iterates over a list or map and concatenates the result of a template for each element. This is commonly used inside a heredoc to build a multiline block from a list variable:
The ~ immediately after for and before endfor is a whitespace strip marker. Without it, each directive line leaves behind its own newline and you end up with blank lines between entries. With it, only the newline that belongs to the generated content (the server ... line) survives.
Multiline strings in Terraform
For anything longer than a single line, Terraform uses heredoc syntax: an opening marker (<< or <<-, plus an identifier you choose), the content, and that same identifier alone on its own closing line.
Skip the heredoc for JSON and YAML. It's tempting to hand-write a JSON policy like the one above inside a heredoc, but a single missing comma will fail at apply time with a confusing error. Use jsonencode() or yamlencode() instead, and let Terraform guarantee valid syntax:
Indented heredocs
A standard heredoc treats every space as literal, which forces the closing marker (and every line of content) flush to the left margin, awkward when the block sits inside a nested resource. Add a hyphen, <<-EOT, and Terraform finds the line with the smallest number of leading spaces, then trims that many spaces from every line:
That produces a string with no leading indentation on the first and third lines, and the second line's extra two spaces preserved relative to the others, letting you indent the whole block to match your code without indenting the actual output.
One more difference from quoted strings: backslash characters inside a heredoc are not treated as escape sequences, they're literal. The only two special sequences that still work are $${ and %%{.
Terraform string functions
HCL ships a full library of built-in string functions. Here are the ones you'll reach for most:
FunctionWhat it doesformat()Printf-style templating, e.g. format("%s-%02d", "web", 3)join() / split()Combine a list into a string, or divide a string into a listreplace()Substring find-and-replaceregex() / regexall()Pattern matching and extractionupper() / lower()Case conversiontrim() / trimspace() / trimprefix() / trimsuffix()Strip characters or whitespace from a stringsubstr()Extract part of a string by offset and lengthstartswith() / endswith() / strcontains()Boolean checks on string contentbase64encode() / base64decode()Base64 conversion, common for cloud-init user datatostring()Explicit conversion to string type
A couple of these in practice, building a consistent, lowercase resource name and cleaning up a variable that might carry stray whitespace:
For the full function-by-function reference across every category, not just strings, see env zero's Terraform Functions Guide and Terraform Map Variable guide for the collection-type equivalents like merge() and optional(). And if split() and join() are what brought you here, they get a full treatment, syntax, alternatives, and worked examples, in Terraform Split and Join Functions: Examples and Best Practices.
How to concatenate strings in Terraform
Three ways to combine strings, and when each one fits:
- Interpolation (
${}) – simplest option for combining a small, fixed number of values:"${var.first}-${var.second}" join()– the right choice when you're combining a list of unknown length, e.g. all the subnet IDs in a VPCformat()– best when you need precise control over layout, padding, or multiple substitutions in a fixed template
If you're splitting a delimited string apart specifically so you can loop over the pieces, pair split() with for_each, a common combination for turning one input variable into several resources.
Managing Terraform strings with env zero
Most of what goes wrong with Terraform strings in a team setting isn't the syntax, it's keeping values consistent, secret, and correctly typed across environments. env zero's variable management is built around that:
- String is the default variable type. Plain text is the most common Terraform Variable value type in env zero, and clicking Load Variables From Code pulls your string-type input variables straight from your
.tffiles at their default values. Complex types (lists, maps, objects) are supported too, entered as HCL or JSON. - Sensitive values stay masked. Rather than interpolating a credential or token directly into a string in your configuration, mark the variable as sensitive. Its value is masked in the UI after saving, so secrets don't end up sitting in plain text where anyone with template access can read them.
- Environment Outputs use string interpolation to cross environment boundaries. As noted above,
${env0::}lets one environment consume another's output value, string outputs only, for now. - Watch your quoting when setting variables via
TF_VAR_*. If you're passing a list or map value through an environment variable instead of the UI, env zero needs a properly formatted string, e.g.export TF_VAR_myvar='["a","b"]', and a missingtypefield on the variable is a common cause of format errors. See Handling Common Errors for the full breakdown.
Key points
Terraform strings can be written as quoted literals or heredocs, and both support ${} interpolation for dropping in dynamic values. Heredocs add multiline support, with an indented <<- variant for keeping code readable, and %{} directives add conditionals and loops that most references skip over. Built-in functions cover everything from case conversion to regex extraction, and env zero's variable management extends the same interpolation pattern across environments, without you needing to hardcode a secret to do it.
Frequently Asked Questions
Q. What is a string in Terraform?
A sequence of characters used to represent text, defined either as a quoted literal in double quotes or as a heredoc for multiline content. Both support interpolation.
Q. What's the difference between a quoted string and a heredoc?
Quoted strings are single-line and support backslash escape sequences like \n and \t. Heredocs span multiple lines, don't process backslash escapes, and are better suited to longer blocks like policy documents or config files.
Q. Can I use an if-statement inside a Terraform string?
Yes, using a %{ if } / %{ else } / %{ endif } directive inside a quoted or heredoc string. It's a template directive, distinct from interpolation, and works alongside a %{ for } directive for loops.
Q. How do I stop a heredoc from picking up unwanted indentation?
Use the indented form, <<-EOT instead of <. Terraform trims the smallest common leading whitespace from every line.
Q. Should I build a JSON string with a heredoc?
Generally no. Use jsonencode() (or yamlencode() for YAML) so Terraform validates the structure for you instead of relying on hand-typed brackets and commas.
Q. How do I concatenate strings in Terraform?
Use ${} interpolation for a small fixed number of values, join() for a list of unknown length, or format() when you need precise control over the output layout.
Terraform Strings: Interpolation, Heredoc & Built-In Functions


Terraform state doesn't stay put forever. Teams outgrow local state files, consolidate multiple backends into one, switch cloud providers, or decide it's time to move off a platform that's no longer working for them - HCP Terraform's free tier ending and its resource-based pricing is a common trigger these days. Whatever the reason, the state file itself is the part everyone's afraid to touch, because it's the only record of what Terraform thinks your infrastructure actually is.
The good news: Terraform has built-in tooling for this, and it's more forgiving than it looks once you understand which command does what. This guide covers the core migration methods and worked examples for the most common backend swaps, plus the specific paths for moving state into and out of env zero.
Why teams migrate state in the first place
A few scenarios come up repeatedly:
- Moving off local state. Local
terraform.tfstatefiles don't support locking or team access, so this is usually the first migration a growing team makes. - Switching cloud providers or regions. An S3 bucket in the wrong region, or a move from AWS to Azure, means the backend needs to move too.
- Consolidating state storage. Multiple teams standardizing on one backend, one bucket structure, or one access-control model.
- Leaving a platform behind. Cost, feature gaps, or workflow friction with a current remote-operations platform - migrating off Terraform Cloud is the version of this we see most often.
Each of these is a backend migration at the mechanical level, you're telling Terraform "the state lives somewhere new now," and asking it to either move the data or just start reading from the new location.
-reconfigure vs -migrate-state: know which one you need
Both flags apply when you change a backend block and run terraform init again. They do different things, and picking the wrong one is the single most common way people scare themselves during a migration:
terraform init -migrate-statecopies your existing state into the new backend. Use this when you want continuity - the new backend should end up with the same state your old one had.terraform init -reconfigureignores any existing state at the new location and just starts fresh with the new backend configuration. Use this when you're intentionally not carrying state over - for example, pointing at a backend that already has the correct state in it.
If you run -reconfigure when you meant -migrate-state, Terraform will think your infrastructure doesn't exist yet and may try to recreate it. Always default to -migrate-state unless you have a specific reason not to.
Method 1: terraform init -migrate-state
This is the standard path for most backend-to-backend moves. The pattern is the same regardless of which backend you're moving to:
- Update the
backendblock in your configuration to point at the new location. - Run
terraform init -migrate-stateand confirm the prompt. - Run
terraform plan— you should see no changes, or only trivial ones. Anything more than that means something in the migration didn't line up.
Local to Amazon S3:
//hcl
terraform {
backend "s3" {
bucket = "my-org-tfstate"
key = "prod/terraform.tfstate"
region = "us-east-1"
}
}After adding this and running terraform init -migrate-state, Terraform detects the backend change, copies your local state into the S3 bucket, and confirms the new backend is active. From that point on, terraform plan reads from S3.
Local to Azure Storage Account:
//hcl
terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "myorgtfstate"
container_name = "tfstate"
key = "prod.terraform.tfstate"
}
}Same flow: the storage account and container need to exist first (with the right access permissions for whoever's running init), then terraform init -migrate-state handles the copy.
Method 2: manual state pull / state push
Sometimes you can't rely on -migrate-state — the target backend doesn't exist as Terraform-managed config yet, or you need to inspect and edit the state before it lands somewhere new. In that case:
terraform state pull > backup.tfstateUpdate the backend configuration, run terraform init (with -reconfigure if the new backend has no state of its own yet), then push the state you pulled:
terraform state push backup.tfstateThis is also the safety net for any migration - always pull a backup before you touch the backend block, regardless of which method you're using.
Reversing direction: remote back to local
Migrations aren't always toward more centralization. Dropping back to local state - for a small project, a one-off environment, or a deliberate architecture change — works the same way in reverse:
terraform state pull > terraform.tfstateRemove the backend block entirely (an unconfigured backend defaults to local), then run terraform init -migrate-state again. Terraform copies the remote state down into a local file and you're back to terraform.tfstate living next to your configuration.
If you're using Terragrunt
Terragrunt wraps this same Terraform mechanism but manages it per-unit via remote_state blocks, as covered in our Terragrunt tutorial. Two commands matter here:
terragrunt backend bootstrap— creates the backend resources (bucket, table, etc.) if they don't exist yet.terragrunt backend migrate old-unit new-unit— moves state between two Terragrunt-managed units.
If you're migrating many workspaces at once, Terragrunt's per-unit structure makes it easier to script the process across all of them rather than repeating the manual steps unit by unit.
Migrating into env zero
Everything above is backend-agnostic, it works whether the destination is env zero or anything else. But if env zero is the destination, there's a more direct path, and which one to use depends on where you're coming from.
env zero supports a few different ways of storing state depending on your setup (covered in more detail here) which affects what "moving state into env zero" actually means for you: bringing your existing external backend along unchanged, or pointing your environment at env zero's own remote backend.
Coming from Terraform Cloud or Terraform Enterprise
This is the most common migration path into env zero, and there's a dedicated tool for it: the env zero Migration Wizard, found under Organization Settings → Migration. Rather than rebuilding your Terraform Cloud or Terraform Enterprise setup by hand, the wizard connects to your organization with a read-only API token, scans your existing workspaces, and recreates them as env zero environments - carrying over variables, variable sets, VCS configuration, project hierarchy, and state in the process. For organizations with many workspaces, it supports a staged migration: move a handful of workspaces first, validate them in env zero, then continue migrating the rest whenever you're ready. A final go-live step locks the source Terraform Cloud/Enterprise workspaces and activates the corresponding env zero environments, so there's no window where both platforms are trying to run deployments at once.
A few things the wizard doesn't carry over automatically, worth planning for post-migration: private module registry contents, Sentinel/OPA policy definitions, run triggers and workspace dependencies, team permissions and RBAC, notification integrations (Slack, email), and SSH keys for repository access. These are all quick to reconfigure directly in env zero once your environments are in place.
This is also the migration path Elevate took after running into concurrency limits and unpredictable resource-based pricing as their infrastructure scaled. Paul Trout, Sr. Cloud Architect at Elevate, described migration as "a very scary word, especially when it's the core piece of infrastructure that drives your production releases" - but with env zero's migration tooling, the team completed the switch, alongside a parallel move from Terraform to OpenTofu, within a few weeks.
If you'd rather migrate one workspace at a time, manually, the process uses Terraform's native cloud block rather than a backend block, since that's what TFC/TFE-style remote state expects:
//hcl
terraform {
cloud {
hostname = "backend.api.env0.com"
organization = "<YOUR_ORGANIZATION_ID>.<YOUR_PROJECT_ID>"
workspaces {
name = "my-prod-resource"
}
}
}The manual path in short:
- Add a
TF_TOKEN_app_terraform_io(orTF_TOKEN_your_tfe_hostfor a custom hostname) environment variable with your TFC/TFE token, at the organization or project level if you're doing this for more than one workspace. - Add
ENV0_SKIP_WORKSPACE=true— without it, env zero will error on workspace names it doesn't recognize when a TFC/TFE-style remote backend is in play. - In env zero, set the environment's Workspace Name to match your existing TFC/TFE workspace name exactly (the name, not the
ws-ID). Don't enable "Use env zero remote Backend" yet. - Run the environment. You should see no changes — this confirms env zero is reading the same state TFC/TFE already had.
- Now go to Environment → Settings, check "Use env zero remote Backend", and save.
- Redeploy. Terraform will report the backend configuration changed and ask to migrate state — confirm, and env zero takes over as the backend from here.
- Optional cleanup: remove the
TF_TOKEN_*variable and anyTF_CLI_ARGS_inityou added for the transition, and drop thecloudblock from your config if you don't need the remote-plan features it enables.
Coming from a self-managed backend (S3, Azure, GCS, etc.)
If your state already lives in a backend you manage yourself, you have a choice: keep using it exactly as-is (env zero doesn't require you to move state storage at all), or move it into env zero's own remote backend. To move it:
//hcl
terraform {
cloud {
hostname = "backend.api.env0.com"
organization = "<YOUR_ORGANIZATION_ID>.<YOUR_PROJECT_ID>"
workspaces {
name = "<YOUR_WORKSPACE_NAME>"
}
}
}Running terraform init -migrate-state against this configuration triggers the standard Terraform migration flow — it'll report it's migrating from your existing backend to the cloud backend and ask for confirmation. Say yes, and env zero automatically detects the incoming state and creates a matching environment for you, named after your workspace. From there, just double-check the VCS details point at your actual repository rather than a placeholder.
If you're coming from Atlantis specifically, there's a dedicated walkthrough that covers the same remote-backend approach in that context.
Migrating state out of env zero
The reverse works the same way any backend-to-backend migration does: remove env zero's backend configuration, add the backend block for wherever you're moving to, and run terraform init -migrate-state. Confirm with terraform plan that nothing unexpected shows up, and if needed, terraform state push backup.tfstate to be explicit about it. Once the state's confirmed in its new home, the env zero environment can be marked inactive.
FAQ
Do I need to migrate state and workspaces at the same time?
Not necessarily - you can migrate state independently of workspace configuration, but if you're moving away from TFC/TFE, the Migration Wizard handles both together and is less error-prone than doing each by hand.
What happens if terraform plan shows changes right after a migration?
Stop and investigate before applying anything. A clean migration should show no changes (or only cosmetic ones like formatting). Unexpected changes usually mean a resource address, provider version, or variable value doesn't match between the old and new setup.
Can I use my own S3 or Azure backend and still get env zero's other features?
Yes, env zero's own remote backend is optional. Environments can keep using an externally managed backend while still getting env zero's governance, cost visibility, and drift detection on top.
Is the Migration Wizard safe to run against production workspaces?
The wizard is designed for exactly that use case, including a staged rollout so you can validate before cutting production traffic over. The standard precautions still apply: pull a state backup first, and verify with terraform plan before treating the migration as complete.
For more on state fundamentals (locking, drift, and the structure of the state file itself) see our guide to the Terraform state file and Terraform best practices for state management.
How to Migrate Terraform State Between Backends


Today we're launching the env zero Free Tier, a free-forever plan that gives platform teams the full env zero orchestration experience rather than a locked-down demo of it. If you've been waiting for a way to try real IaC orchestration on your own terms, this is your front door.
Why we built the env zero Free Tier
Platform engineering is a bottom-up discipline. You don't adopt a new IaC platform because someone signed a contract. You adopt it because you spun it up on a Friday, pointed it at a repo, and saw it solve the orchestration and self-service problems you live with every day.
For too long, trying env zero meant talking to us first, and that's the wrong answer to "I just want to see if this works for my team." So we changed it. The Free Tier is self-serve from the first click. Create an org, connect your VCS, and run.
With the IaC landscape shifting and long-standing free options disappearing, a lot of teams are re-evaluating how they orchestrate Terraform and OpenTofu right now. We wanted env zero to be the obvious place to land. We looked hard at the competitive landscape and sought to include a feature and entitlements set that would make env zero the superior option.
Who it's for
The Free Tier is built for the platform engineer standing up self-service infrastructure for their org, whether that's a solo practitioner proving out a workflow or a small platform team giving developers a paved road to deploy on.
It's a fit if you want to:
- Give your developers self-service environments without handing them the keys to production
- Run drift detection against your live infrastructure so state surprises stop being surprises
- Wire in SSO and bring your whole team in from day one, not just yourself and one teammate
- Orchestrate Terraform and OpenTofu with real guardrails, before you've had a single procurement conversation
What you get
The Free Tier ships with the complete env zero Navigator feature set. We took the position early that a paying customer should never receive less than a free user does, so instead of carving out a feature-limited "lite" plan, we gave the free tier the full product and set generous usage limits around it.
| Price | $0, in perpetuity |
|---|---|
| Runs | 250 / month |
| Environments | Up to 30 |
| Deploying users | Unlimited, humans and agents |
| Features | Full Navigator feature set, including drift detection and OIDC SSO |
| Support | Community support |
A few things worth spelling out for the way platform teams actually work:
A "run" is an outcome. One run equals one successful apply or one drift detection. Plans and failed runs don't burn your monthly allowance, so you're only metered on work that actually happened.
Unlimited users, including agents. There's no per-seat gate and no "invite two people, then pay." Bring your whole platform team and your automation. As IaC pipelines increasingly include non-human actors, we didn't want a seat cap to be the thing that boxed you in.
Drift detection and SSO are included. These are the capabilities teams usually have to upgrade to reach elsewhere. On env zero they're part of the free experience, because they're part of running infrastructure responsibly.
Where the limits are, and what's next
The Free Tier is designed to run real workloads, not just a hello-world. The two dials that define it are 250 runs per month and 30 environments. When your team consistently pushes past those, that's the natural signal you've outgrown free. Our paid tiers, Cloud Navigator and Cloud Pilot, lift those ceilings and add direct support and more advanced capabilities as you scale.
There's no downgrade trap and no bait-and-switch. Free stays free, and the path up is there when you need it and not before.
Get started
You can be deploying in minutes:
- Sign up for env zero. Every new self-serve account lands on the Free Tier automatically.
- Connect your VCS and point env zero at an IaC repo.
- Run your first deployment, turn on drift detection, and invite your team.
No trial countdown. No credit card. No gatekeeper.
FAQ
Is it really free forever? Yes. The Free Tier is $0 in perpetuity. It isn't a promotional rate or a countdown to a paid plan.
Is there a trial? Do I need a credit card? No trial and no credit card. Every new self-serve account lands directly on the Free Tier, so you start on Free from your very first login and stay there. There's no trial period to expire and nothing that quietly downgrades on you later.
What counts as a run? A run is one successful apply or one drift detection. Plans and failed runs don't count against your monthly total, so you're only ever metered on work that completed.
What happens when I hit 250 runs or 30 environments? Those are the two limits that define the Free Tier. When you reach a cap, env zero lets you know and shows you the path to more capacity through Cloud Navigator or Cloud Pilot. Your existing environments and configuration stay intact.
Do I get fewer features on Free than on a paid plan? No. The Free Tier includes the complete Navigator feature set, drift detection and OIDC SSO included. The paid tiers raise your usage limits and add direct support and more advanced capabilities as you scale, rather than unlocking basic functionality.
Can I add my whole team? Yes. Deploying users are unlimited on Free, both human teammates and automation or agents. There's no per-seat charge.
What support is included? Free Tier comes with community support. Direct support is part of the Cloud Navigator and Cloud Pilot tiers. Paid support plans are available for Free Tier users, simply Contact Us.
How do I upgrade when I outgrow Free? When your team consistently pushes past the run or environment limits, you can move up to Cloud Navigator or Cloud Pilot for higher ceilings, direct support, and additional capabilities. Your work carries over.
Introducing the env zero Free Tier: full-featured IaC orchestration, free forever


You run [.code]terraform apply[.code], it fails partway through, and the next command you run refuses to move: [.code]Error acquiring the state lock[.code]. Nothing is broken yet, but nothing will proceed either until the lock is cleared.
This guide covers what [.code]force-unlock[.code] actually does, when it is safe to use, how to find the lock ID on every major backend, and what to do if the command itself does not fix things.
What does terraform force-unlock do?
Terraform locks state before any operation that could write to it. The lock stops two processes from writing to the same state file at once, which is the most common way a state file gets corrupted. Locking happens automatically and silently on every plan and apply where the backend supports it. You will not see a message unless acquiring the lock takes longer than expected.
[.code]force-unlock[.code] is the manual override for when that automatic process gets stuck. It removes the lock record so a new operation can proceed. According to HashiCorp, the command does not modify your infrastructure, and on most backends it does not touch your state data either. It just clears the record that says the state is currently held.
Usage:
terraform force-unlock [options] LOCK_ID
The only option is [.code]-force[.code], which skips the yes or no confirmation prompt. That is useful inside a script or CI job where nothing is available to type “yes” into. Otherwise leave it off, since the confirmation step is the last chance to catch a mistake before you unlock something someone else is actively using.
The lock ID is not optional and is not guessable. Terraform prints it in the error message when a lock is already held, and [.code]force-unlock[.code] requires an exact match. Per HashiCorp's own documentation, the ID works as a nonce, a one-time verification token that ensures a lock and an unlock target the same lock. That is deliberate: you can only release a lock you can identify, not just any lock on the state file.
One thing worth flagging up front: on the local backend, a stuck lock can only be cleared by the same machine and user that created it. There is no separate process to force it from elsewhere, which is one more reason most teams move to a remote backend, such as the ones covered in this backend configuration guide, before this becomes a live problem.
When should you use the force-unlock command?
Treat [.code]force-unlock[.code] as a last resort, not a first response. If two operations are genuinely running against the same state at the same time, forcing a lock open defeats the entire purpose of state locking and can leave you with a corrupted state file. Only run it when you are certain the process that created the lock is no longer active.
A stuck lock usually traces back to one of a few causes:
- A [.code]terraform apply[.code] or [.code]plan[.code] was cancelled or errored mid-run, for example because a CI job timed out or someone hit Ctrl+C, so Terraform never reached the step where it releases the lock.
- The machine or build agent running Terraform lost its network connection to the backend before the lock could be released.
- The backend storage itself changed mid-operation, for example a Terraform run modifying firewall rules, private endpoints, or access policies on the very storage account that holds the state file.
If none of those match your situation and you are not sure why the lock exists, treat that as a reason to investigate before clearing it, not a reason to assume it is safe to force.
Where to find the lock ID for every backend
In most cases you will not need to go looking. The lock ID appears directly in the [.code]Error acquiring the state lock[.code] message, under the [.code]ID:[.code] field, alongside who holds it and when it was created. The backend-specific detail below matters mainly when you are troubleshooting secondhand, for example clearing a lock a teammate reported without a fresh error message in front of you.
Local backend
Terraform writes a [.code].terraform.tfstate.lock.info[.code] file next to the state file while an operation is in progress. It is a small JSON object containing the lock [.code]ID[.code], the operation type, and who created it. On clean exit, Terraform deletes this file automatically. As noted above, a lock created by one machine cannot be released by [.code]force-unlock[.code] from a different one.
Amazon S3
As of Terraform 1.11, the S3 backend supports native state locking through the [.code]use_lockfile[.code] argument, and no longer requires a separate DynamoDB table. Setting it to [.code]true[.code] tells Terraform to create a lock object in the same S3 bucket as your state, using conditional writes so only one process can create it at a time.
terraform {
backend "s3" {
bucket = "mybucket"
key = "path/to/my/key"
region = "us-east-1"
use_lockfile = true
}
}
With native locking, the lock ID is whatever the error message reports; there is no separate table to query. If your configuration still uses the older [.code]dynamodb_table[.code] argument, note that HashiCorp has deprecated it in favor of [.code]use_lockfile[.code]. On that legacy path, the lock lives as an item in the DynamoDB table, keyed by a partition key named [.code]LockID[.code], and you can inspect it directly:
aws dynamodb get-item \
--table-name your-lock-table \
--key '{"LockID": {"S": "your-bucket/path/to/terraform.tfstate"}}'
Azure Blob Storage
Azure Blob Storage implements locking through native blob leases, with no extra backend configuration required. If a run is interrupted mid-apply, the lease can be left in place. The lock ID appears in the error message, but you can also inspect the lease state directly:
az storage blob show \
--account-name YOUR_STORAGE_ACCOUNT \
--container-name YOUR_CONTAINER \
--name path/to/terraform.tfstate \
--query 'properties.lease'
If [.code]force-unlock[.code] is not an option, for example the lock ID is unavailable, you can break the lease directly through the Azure CLI, which achieves the same result at the storage layer:
az storage blob lease break \
--account-name YOUR_STORAGE_ACCOUNT \
--container-name YOUR_CONTAINER \
--blob-name path/to/terraform.tfstate
Google Cloud Storage
The GCS backend also locks natively with zero extra configuration. Terraform writes a lock object at [.code]/.tflock[.code] in the same bucket as your state, and the lock ID is the object's generation number, which is included in the error message. Deleting that object directly is the manual equivalent of [.code]force-unlock[.code] if the CLI command fails for some reason.
HCP Terraform and Terraform Enterprise
This is a common point of confusion: [.code]terraform force-unlock[.code] is a CLI command that works against backends where Terraform itself manages the lock file. HCP Terraform and Terraform Enterprise instead lock and unlock workspaces through their own UI and API, not the CLI command. In the workspace's Actions menu, you can select Lock workspace or Unlock workspace directly, or call the workspaces API endpoint to do the same thing from automation.
Consul
With the Consul backend, lock information lives in the Consul key-value store rather than in a file. You can list it with the [.code]consul kv get [.code] command, or query the same data through Consul's HTTP API.
Using terraform force-unlock: a worked example
- Identify the lock ID from the error message. For example: [.code]Lock Info: ID: b8814894-4a5f-217b-e97b-c4f5c02a1f88[.code].
- Confirm nobody else is running an operation against this state. Check your CI/CD dashboard, ask your team, or check the environment's deployment history if you are running on a platform that centralizes this, before assuming the lock is actually stale.
- Run the command with the ID from step one: [.code]terraform force-unlock b8814894-4a5f-217b-e97b-c4f5c02a1f88[.code]. Confirm the prompt with [.code]yes[.code], or add [.code]-force[.code] if you are running this non-interactively.
- Verify the fix by re-running the command that originally failed, such as [.code]terraform plan[.code]. If it proceeds past the locking step without error, the lock is cleared.
Unlocking remote state: alternatives to force-unlock
Wait instead of forcing: -lock-timeout
If two operations occasionally overlap for a few seconds, for example two CI jobs kicking off close together, [.code]force-unlock[.code] is the wrong tool. The [.code]-lock-timeout[.code] flag tells Terraform to wait for the lock to clear on its own instead of failing immediately:
terraform plan -lock-timeout=5m
This is worth setting as a default in CI pipelines that run plan, apply, or destroy operations back to back, so a brief overlap resolves itself instead of surfacing as a lock error at all.
Manual removal as a last resort
Occasionally [.code]force-unlock[.code] itself fails, usually because the backend is unreachable or credentials cannot reach the lock record. HashiCorp's guide to recovering state from backup covers this scenario directly. In that situation, the remaining options are backend-specific: delete the lock object from S3 or GCS, edit or remove the DynamoDB item, or break the Azure blob lease as shown above. All of these bypass Terraform entirely, so treat them with the same caution as [.code]force-unlock[.code] itself.
Coordinate before you unlock
Whichever method you use, confirm no other process is mid-write before you touch the lock, and consider pulling a backup first with [.code]terraform state pull[.code]. Never use [.code]-lock=false[.code] as a standing workaround for frequent lock errors. It disables the protection entirely rather than resolving whatever is causing the contention.
Troubleshooting force-unlock errors
The lock ID does not match
[.code]force-unlock[.code] will refuse an ID that does not match the current lock. This almost always means you are using a stale ID from an old error message. Re-run the failing command to get the current lock's ID and try again.
Permission errors during force-unlock
Clearing a lock requires write or delete access to wherever the lock record lives, for example [.code]s3:DeleteObject[.code] on the lock object, or the equivalent DynamoDB, GCS, or Azure permission. A permissions error here usually points to the credentials Terraform is running with, not the lock itself.
The same lock error comes back immediately
If you clear a lock and it reappears right away, something is still actively writing to that state. Stop and investigate before unlocking again. This pattern usually means step two of the worked example above was skipped.
Managing state locking at scale with env zero
Clearing a stuck lock by hand does not scale once a platform team is managing hundreds of environments across multiple backends. Ad Hoc Tasks in env zero let you run a command, including [.code]terraform force-unlock -force LOCK_ID[.code], directly on the environment's deployment container from the UI. That means resolving a stuck lock does not require local CLI access, a checked-out copy of the Terraform configuration, or direct credentials to the backend that holds the state.
By default, ad hoc tasks are restricted to organization administrators, since they allow arbitrary commands against a live deployment container. Teams that want to delegate lock-clearing to platform engineers without granting full admin access can do that with a custom role scoped to just that permission.
It is worth distinguishing this from Environment Locking in env zero, which is a separate, deliberate governance control rather than Terraform's automatic state lock. Locking an environment in env zero blocks deploys, destroys, plans, and drift detection outright, with a reason attached for anyone else who looks at it, and it stays in effect until someone with permission unlocks it. A Terraform state lock, by contrast, is transient by design and normally clears itself within seconds. If you are troubleshooting a “locked” environment in env zero and [.code]force-unlock[.code] does not seem relevant, this distinction is usually why.
Key takeaways
- [.code]terraform force-unlock LOCK_ID[.code] manually clears a stuck state lock. It does not touch your infrastructure, and on most backends it does not touch your state data either.
- Only use it when you are certain the process that created the lock is no longer running. Unlocking an active operation risks a corrupted state file.
- The lock ID is usually sitting right in the error message. You only need to hunt through backend-specific tooling when troubleshooting without that message in hand.
- S3 no longer needs DynamoDB for locking. [.code]use_lockfile = true[.code] has been the supported path since Terraform 1.11.
- [.code]-lock-timeout[.code] prevents most stuck-lock situations in CI before they happen, by waiting instead of failing immediately.
Frequently asked questions
Q. How do I fix a Terraform state lock?
Run [.code]terraform force-unlock LOCK_ID[.code], using the ID from the [.code]Error acquiring the state lock[.code] message. Only do this once you are certain no other operation is currently running against the same state.
Q. What is Terraform state locking for?
State locking prevents two operations from writing to the same state file at the same time, which is one of the most common causes of state corruption. Terraform acquires the lock automatically before any operation that could write state and releases it when the operation finishes.
Q. Can force-unlock corrupt my Terraform state?
[.code]force-unlock[.code] itself does not modify your infrastructure or your state data; it only removes the lock record. The risk is indirect: if you unlock a state that another process is actively writing to, that process and yours can both write at once, which can corrupt the state file.
Q. Does the S3 backend still need DynamoDB for state locking?
No. Since Terraform 1.11, the S3 backend supports native locking through [.code]use_lockfile = true[.code], using S3 conditional writes instead of a separate DynamoDB table. The older [.code]dynamodb_table[.code] argument still works but is deprecated.
Q. How do I avoid stuck state locks in the first place?
Avoid cancelling Terraform runs mid-operation, set [.code]-lock-timeout[.code] in CI so brief overlaps wait instead of failing, and use a platform that centralizes deployment history so you can quickly confirm whether a lock is stale before clearing it.
Terraform Force-Unlock: How to Safely Unlock a Locked State File

Hello, env zero fans! As some of you know, we have almost unlimited extensibility with 3rd party tools, using our custom workflows. You can hook in pretty much any tool, in any phase of the deployment. Today, we’re going to talk about how to prevent cloud misconfigurations before they start. We’re going to do this by chaining a tool in the deployment after the terraform plan phase. This is where our friends at Bridgecrew come in. Just like we at env zero have open-sourced the Terratag module of our platform, Bridgecrew has open-sourced Checkov!
Checkov
Checkov is a static code analysis tool for infrastructure-as-code. It scans cloud infrastructure managed in Terraform, Cloudformation, Kubernetes, Arm templates, or Serverless Framework and detects misconfigurations.

Setup
For illustration purposes, we’re going to use Bridgecrew’s demo application called TerraGoat. TerraGoat is Bridgecrew’s “Vulnerable by Design” Terraform repository. TerraGoat is a learning and training project that demonstrates how common configuration errors can find their way into production cloud environments.
DISCLAIMER: DO NOT ACTUALLY DEPLOY THIS APPLICATION INTO YOUR CLOUD INFRASTRUCTURE. IT IS PURPOSELY COMPROMISED.
I have created a template of TerraGoat inside of env zero and linked it to our Bridgecrew Demo project.

The only other thing we have to do is to actually call Checkov to do the check during the deployment. We need to do this after the Terraform plan phase, so that we have a plan to check. Here is what the env0.yml file will look like:
This adds 3 commands that run after the Terraform Plan, and before Terraform Apply. We put it here so that the Apply doesn’t run in case of failures. We don’t want to see the errors after the resources are applied. We want the deployment to fail if there are errors.
This command installs Checkov into our runtime environment using the pip3 package installer so we can run it against our Terraform plan.
This command essentially formats our .tf-plan file into tf.json so that it can be parsed and run against Checkov.
This command has a lot going on and is in 2 parts. First, it quietly executes Checkov against our tf.json (the reformatted tf.plan file) and looks for a 0 exit code. The double pipe || tells bash to only execute the 2nd command if the exit code of the first command is not 0. So if your Checkov results are clear, your deployment gets the 0 exit code and continues on with the deployment.
If not, then the second part of the command runs. Knowing if this part runs, it is because of a failure, we’re just going to format our error message here. We run Checkov again so we can pipe the error with the echoed error notification text to the console. The 1>&2 routs stdout to stderror, and the exit 1 code tells env zero that the stage failed, and to end the deployment run.

The env zero platform will parse the error, and give you the clear error printed on the Environment deployment page. But, if you want the full logs from Checkov, you can find those in the After: Terraform Plan deployment logs.

And that’s it! A little bit of YAML, and you’ve implemented Checkov to protect yourself against the deployment of misconfigured cloud resources. That is instantly added value to your organization by shifting the security left in your deployment process with env0.
You can find more information on Checkov here. You can find the open-source repository on GitHub. And be sure to see how you can automate your infrastructure security from commit to cloud at Bridgecrew.io.
Better Together: Checkov and env0



