
In the last several years, Kubernetes has become the “go to” standard for managing and orchestrating containerized workloads. Thanks to it’s vendor agnostic nature, you can easily run Kubernetes almost anywhere, and in fact, all the major cloud vendors offer a managed Kubernetes service (AWS EKS, Google GKE, and Azure AKS).
With Kubernetes, one of the key advantages is the ease of managing multiple environments and workloads in a single cluster, by separating the cluster into logical areas using namespaces. This post will dive into how we can manage this by using Terraform to both manage the cluster provisioning as well as manage the namespaces.
Multiple Environments in One Cluster
When using Kubernetes for a team, you usually want to have an isolated environment for each developer, branch, or pull request. There are a few ways to achieve that using Kubernetes: one way is to create a full blown cluster for each division, but the way we’re focusing on is using Kubernetes namespaces feature.Â
While the namespaces feature is quite powerful it’s not without its complications, especially with a large team of developers: How many namespaces do I have on my cluster? Can I remove them? Does anyone use them? Can I schedule them to automatically shut down during night time and weekend? Can I have policies on who can run what and where? Fortunately, with a good management platform, a lot of this can be alleviated.
Why Manage Kubernetes using Terraform?
As a cloud-native technology that is deployed at wide scale, it’s quite common to manage the deployment of the infrastructure of Kubernetes clusters using Terraform. And where you have many clusters to manage (Dev, Staging, Production, etc.), Terraform allows you to maintain a consistent configuration for the cluster and underlying infrastructure while creating as many clusters as you like with the same configuration in a reliable and easy way.
Terraform's multi-cloud approach lets you use any cloud provider you wish, including using the native managed services that I’ve mentioned above.
Similarly, when managing namespaces there’s a few ways, but using Terraform is probably the best overall. The main advantages of using Terraform are:
- Use the same configuration language to provision the Kubernetes infrastructure and to deploy applications into it.
- drift detection - `terraform plan` will always present you the difference between reality at a given time and the config you intend to apply.
- full lifecycle management - Terraform doesn't just initially create resources, but offers a single command for creation, update, and deletion of tracked resources without needing to inspect the API to identify those resources.
- synchronous feedback - While asynchronous behaviour is often useful, sometimes it's counter-productive as the job of identifying operation results (failures or details of created resource) is left to the user. e.g. you don't have IP/hostname of load balancer until it has finished provisioning, hence you can't create any DNS record pointing to it.
- Graph of relationships - Terraform understands relationships between resources which may help in scheduling - e.g. if a Persistent Volume Claim claims space from a particular Persistent Volume, Terraform won't even attempt to create the PVC if creation of the PV has failed.
Putting It Into Action
I’ve taken the Terraform code from a simple AWS EKS Terraform example, popular for its ease, to provision a new EKS cluster in my AWS account.
To run the actual Terraform, I’m using env0 which allows me to quickly deploy and manage my environments based on Terraform templates. If you’re just getting started with env0, you should first get your organization set up and create a new template (check out the getting started documentation for help there).
Now we will run the template which will provision the EKS cluster with envzero and get the outputs:

Now that our EKS cluster is set up, we can create a new template for the namespaces and our deployment using the cluster name from the outputs.
 Set up Namespace + Deployment
First we use the following code to authenticate against the Kubernetes cluster that we’ve created and create a new deployment with an ngnix:
So now let’s make (and run) a new template using that Terraform code which will create the namespace and run the deployment:

Running kubectl to get the namespace and the deployment and we can see the resources we’ve created inside the cluster:

You can see that we use the aws_eks_cluster and the aws_eks_cluster_auth data resource to get the authentication data, and to be able to create the namespace and the deployment in the cluster.
There is another way to achieve this and import the state file of the Kubernetes cluster you created, but for simplicity we took a simpler approach. You can read more about it here.Â
Take advantage of More Features
So now that we have a template we can create as many namespaces and deployments as we want, as well as leverage some of the more advanced features of our management platform.
Scheduling
First, I want to make sure that those resources aren’t running over night and weekends so let’s set up scheduling for those namespaces:

Now, envzero will automatically destroy those namespaces on each workday at midnight and will deploy them at 9am when I start my workday.
You can even do that on the entire Kubernetes cluster to save even more money.
Dev Team Environments
Next, now that we have created an easy way for self service provisioning of environments, we want to set up appropriate permissions for everyone on the team to deploy their own environments. In most cases, we want the EKS cluster to be managed by the DevOps team, while the rest of the dev team should be able to provision their own individual environments.
You can achieve this by managing different projects with different permissions for each user.Â
So now I will give our DevOps team the ability to provision the Kubernetes cluster by assigning them a “Deployer” role, but the developers will be assigned as a Planners so they can request changes on the cluster but will need the approval of the DevOps to make the changes.

For the ephemeral namespaces Project the developers can have a “Deployer” role so they will be able to create environments whenever they like, but everything will be cost effective and no resource will be left behind.
SummaryÂ
As this is a simple example of provisioning an EKS cluster with relevant deployment, we actually created a balance between a self service way to get resources and governance and a cost efficient way to manage those cloud resources without losing control.
In addition you can achieve this with any other managed kubernetes service like AKS and GKE, or even just a self managed cluster.
You can also leverage env0’s API and CLI to fully automate a per pull request environment, so each PR will get its own namespace and deployment, and once the PR will be merged to master, it will automatically tear down those resources. You can read more about this concept in a great blog post by Avner Sorek.
There is a lot more you can achieve with Kubernetes and Terraform using env0, so go ahead and try it yourself.
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.tfstate‍Update 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



