Terraform can deploy your application. The question is not whether it can, it is whether putting your release process inside a state file is a trade you want to make.
This comes up on every team that adopts Terraform and does it well. The infrastructure is declarative, reproducible, and reviewed, so the natural next thought is to bring deployments into the same workflow. Sometimes that is right. More often it produces a system where shipping a one-line copy change requires a plan, an apply, and a lock on shared state, and nobody can quite say when it went wrong.
Table of contents
- What Terraform is actually built for
- Where the line sits in practice
- What breaks when you cross it
- A structure that stays maintainable
- Migrations, secrets, and the two things not to put in state
- When Terraform is not the right tool at all
- How this fits the rest of the stack
- FAQ
What Terraform is actually built for
Terraform’s model is a desired end state, a record of what it last created, and a diff between them. You describe what should exist, it works out what to add, change, or destroy, and it writes down the result so it can do the same next time.
That model fits infrastructure exactly. Networks, databases, DNS records, permissions, and storage buckets are long-lived, they change rarely, and there is one correct configuration at any moment. Terraform’s whole design assumes the thing being managed persists between runs and that convergence to a described state is the goal.
Application deployment has almost the opposite shape. Releases are frequent, they are events rather than states, the interesting operations are ordering and rollback, and success is often partial by design because that is what a rolling deploy is. Nothing about that maps cleanly onto converge to a described state.
Where the line sits in practice
The workable division, and the one most teams end up at eventually, is by lifetime. Terraform owns what outlives a release; the deployment pipeline owns the release.
- Terraform. The database instance, the network and its rules, DNS records, storage, permissions and roles, the service definition, and the environment’s shape. Things you create once and change every few months.
- The pipeline. Building the image, pushing it, telling the platform to run the new version, running migrations, running smoke tests, and rolling back. Things that happen several times a day.
The seam is the version identifier. Terraform creates the service and declares that it runs some image; the pipeline decides which tag that is. Keep the seam there and both tools do what they are good at.
The counter-case is real and worth naming. If your entire deployment genuinely is a resource change, because the platform’s API is declarative and a release is nothing more than an updated attribute, then doing it in Terraform is coherent and adds no ceremony. That is a narrower set of situations than it first appears, and it stops being true the moment you want ordered steps or a conditional rollback.
What breaks when you cross it
Four specific failure modes, all of which show up within a few months rather than immediately.
- State lock contention. Terraform locks state during an apply. If deploys go through Terraform, two teams deploying at once means one waits, and an interrupted apply can leave a lock that needs clearing by hand. Deploys should not queue behind each other because of a file.
- Perpetual diffs. The platform changes something about the running service on its own, such as a replica count from autoscaling. Terraform sees drift and wants to change it back on every plan, so you add
ignore_changes, and the resource stops being fully managed while still looking like it is. - No useful rollback. Rolling back means editing the configuration to the previous version and applying, which is a code change, a review, and a plan when what you wanted was a button. Meanwhile the deploy tool you bypassed had the previous version sitting there.
- Blast radius. A deploy now runs a plan that touches your whole environment. A misapplied change to a shared module can propose destroying a database while you were trying to ship a CSS fix.
-targetnarrows it and is explicitly documented as an exceptional-use flag, not a workflow.
The pattern in all four is the same: you inherit the semantics of a convergence engine for an operation that is not a convergence.
A structure that stays maintainable
Assuming Terraform is doing the infrastructure half, a few choices keep it pleasant as the project grows.
- Remote state with locking, from the first day. Local state is fine until a second person touches it, and the migration is more annoying than starting correctly.
- Separate state per environment. Staging and production should not share a state file. It is the cheapest possible protection against applying the wrong plan to the wrong place.
- Small root modules. One that fails should not block everything else. A single state file describing an entire estate makes every apply a whole-estate risk.
- Version the providers. An unpinned provider that upgrades itself between applies is a plan diff nobody wrote.
- Plan in CI, apply deliberately. Post the plan output on the pull request so the diff is reviewed like code, and keep apply as an explicit action rather than something that happens on merge.
Point five is the one that pays for itself most often. A plan attached to a pull request turns the question of what will this change do from a discussion into a document.
Migrations, secrets, and the two things not to put in state
Two categories deserve a specific warning because both are common and both are unpleasant to undo.
Database migrations do not belong in Terraform. They are ordered, they are not idempotent in the way Terraform assumes, they sometimes need to run before the new code and sometimes after, and a failed migration needs a considered response rather than an automatic retry. Run them from the pipeline, in a step you can see and control.
Secrets are worse. Anything Terraform manages ends up in the state file in plain text, including values you passed in as sensitive variables. Marking a variable sensitive suppresses it from console output; it does not encrypt it in state. If your state file contains database passwords, then your state backend is now a secret store and needs to be treated as one, with encryption at rest and tight access.
# The value is hidden in output. It is still stored in state.
variable "db_password" {
type = string
sensitive = true
}
The cleaner arrangement is for Terraform to create the secret container and for the actual values to be injected at runtime as environment variables, so the sensitive material never passes through a plan at all.
When Terraform is not the right tool at all
Worth saying plainly, because infrastructure as code carries enough momentum that teams adopt it before asking whether they have the problem it solves.
Terraform earns its keep when you have many resources, more than one environment, more than one person changing them, and a genuine need to recreate the whole thing reliably. Under those conditions it is close to indispensable.
It costs more than it returns when the entire infrastructure is one service, one database, and a domain. That is four resources, created once, changed twice a year. Managing them through a dashboard and writing down what you did is not a worse engineering practice; it is a correctly sized one. The state file, the provider versions, the locking, and the review cycle are overhead that has to be paid for by complexity that exists.
The honest test: if you cannot describe a scenario in which you would run apply against an empty state and be glad you could, you are probably carrying the tool rather than using it.
How this fits the rest of the stack
Most of this argument dissolves when the platform’s own deploy path is good enough that there is nothing to automate around it. If a push to the repository produces a build log, a live route, and a previous version you can roll back to in one click, the deployment half of the problem does not need a tool at all, and Terraform is left doing the durable infrastructure it is genuinely good at. That is the shape on RunxBuild: services build from GitHub with deploy history and rollback, and databases, domains, and storage are created once and left alone. If you are working out what that environment costs before you build it, the RunxBuild hosting calculator prices the service, the database, and the storage as separate line items.
Useful related references:
- Terraform Replace: lifecycle.replace_triggered_by and -replace
- What Is an S3 Backend? Terraform State That Two People Can Share
- Terraform Helm Provider: Managing Charts as Infrastructure
- Services on RunxBuild
FAQ
Should Terraform be used to deploy applications?
Usually not. Terraform converges toward a described end state, which fits long-lived infrastructure and fits releases badly, since releases are ordered events that need rollback and partial success. Let Terraform own the database, network, DNS, permissions, and service definition, and let a pipeline own building the image and shipping the new version.
Can Terraform deploy application code at all?
Yes, if the platform exposes deployment as a resource attribute and a release is genuinely just an updated value. That is coherent and adds no ceremony. It stops working as soon as you need ordered steps, a migration, a conditional rollback, or a deploy that does not block on a state lock.
What is the problem with state locking during deploys?
Terraform locks state for the duration of an apply, so if deploys run through Terraform they serialise: two people shipping at once means one waits. An interrupted apply can also leave a stale lock that has to be cleared by hand, which turns a routine deploy into an incident.
Should database migrations run in Terraform?
No. Migrations are ordered and not idempotent in the way Terraform’s model assumes, they sometimes must run before the new code and sometimes after, and a failure needs a considered response rather than a retry on the next apply. Run them as an explicit pipeline step.
Are secrets safe in Terraform state?
No. Anything Terraform manages is written to the state file in plain text, including variables marked sensitive, which only suppresses console output. If secrets pass through Terraform, the state backend becomes a secret store and needs encryption at rest and restricted access. Injecting values at runtime as environment variables avoids the problem.