Infrastructure as Code Services in India
Terraform, OpenTofu, Pulumi, CloudFormation and CDK, built by an offshore team that treats state, drift and plan review as the real work. We take estates that were clicked together in a console and turn them into something you can rebuild, review and audit.
Why Does Terraform That Works on a Laptop Fall Apart in a Team?
Almost nobody calls us because they cannot write HCL. The syntax is not hard, the AWS provider documentation is decent, and a competent backend engineer can stand up a VPC in an afternoon. What brings people to infrastructure as code services in India is the second year, when the thing that one person wrote has four people editing it and the failure modes stop being syntax errors.
The first symptom is usually fear. Someone runs terraform plan and gets 47 changes they did not ask for, most of them destroying things. So they stop running it. Now the repository is a historical record of what the infrastructure looked like at some point in 2023, and every real change goes through the console because that is the only route anyone trusts. The code and the account have quietly become two different systems that share a naming convention.
The second symptom is a state file that has become load-bearing and undefended. It is in an S3 bucket somebody created by hand, without versioning, without a lock, and three engineers plus a CI runner all have write access to it. One Tuesday two applies overlap. Terraform writes a state that describes neither the old infrastructure nor the new, and the next plan proposes to recreate the production database because it can no longer find it.
You can survive that. Recovering from it costs a day and a lot of adrenaline if the bucket had versioning enabled, and considerably more if it did not.
The third symptom is subtler and does more long-term damage. Everything works, but every environment is a copy-paste of the last one with the strings changed, so a change to the ALB configuration has to be applied in five places and lands in three. Staging and production diverge over months rather than in a single event, and the divergence is only discovered during an incident, when the thing you tested on turns out not to have been the thing you deployed to.
None of those are Terraform problems. They are the problems that appear when infrastructure code gets treated as a scripting convenience rather than as a system with a state store, a concurrency model, a release process and an audit trail. That is the work we do.
What an Infrastructure as Code Engagement Covers
The order below is roughly the order we build in, and the order matters more than the checklist. Writing beautiful reusable modules before the state backend is safe is the most common way to waste a month. What follows is the full surface area; a given engagement picks a subset after the assessment.
Assessment and the current-state map
Two weeks of reading before writing. What is in code, what is not, which accounts and subscriptions exist, who has write access, where state lives, what the pipeline does today, and which resources nobody dares touch. We produce an inventory of unmanaged resources per account, because that number is what determines whether this is a three-month job or a three-week one, and most teams have never counted it.
The single most useful output of this phase is usually the list of things that must never be destroyed. Databases, S3 buckets holding customer data, DNS zones, KMS keys, anything with a certificate attached. That list becomes lifecycle rules and policy checks before anyone runs an apply.
State architecture
Where state lives, how it is locked, how it is split, who can read it. State splitting is a design decision with real consequences: one state file for an entire estate means every plan takes eleven minutes and every apply is a full-estate risk, while a state file per resource means you spend your life wiring outputs between them. We usually land on a split by lifecycle and blast radius, which tends to mean networking separate from data stores separate from applications, per account, per region.
Module design and a versioned module library
Shared modules for the patterns you use repeatedly, versioned with git tags and consumed by version rather than by branch. The library is deliberately small at the start. Three good modules that everyone uses beat twenty that each fit one caller.
Pipeline and the plan-review gate
Plan on pull request, apply on merge, with the plan output posted where the reviewer can read it. Atlantis if you want it self-hosted and free, HCP Terraform or Spacelift or env0 if you want the policy engine and the run history without operating it. The gate is the point: no human runs apply from a terminal against production once this is live.
Policy as code
Automated rules that fail a plan before it reaches a human. Encryption at rest, no public ingress on port 22, mandatory tags, approved instance families, no IAM policy with a wildcard action on a wildcard resource. Checkov and Trivy for the security baseline, OPA or Sentinel for organisation-specific rules that a generic scanner will never know about.
Secrets handling
Secrets read at apply time from Vault, AWS Secrets Manager, SSM Parameter Store or Azure Key Vault, never committed, and the state backend treated as a secret store because Terraform will write sensitive attributes into it in plaintext regardless of how you marked them.
Drift detection and reconciliation
A scheduled refresh-only plan that reports differences, routed to a channel a human reads, with an agreed process for what happens when it fires. Detection without a reconciliation process becomes noise inside three weeks.
Import of existing infrastructure
Bringing the unmanaged inventory under code in batches, verifying with an empty plan at each step. This is the slowest and least glamorous part of most engagements and it is where the value is, because an estate that is 60 percent managed gives you almost none of the benefits of one that is 95 percent managed.
Testing and CI for the infrastructure repository
Static analysis on every commit, plan assertions on shared modules, and a real provisioning test for modules that several teams depend on. Plus formatting and documentation generation, because a module nobody can read is a module people will copy instead of call.
Handover, runbooks and the training week
An architecture note explaining the state layout and why it is split that way, runbooks for the failures that actually happen (lock stuck, state corrupted, provider upgrade broke a resource, drift alert fired), and a working session with your engineers where they make a change end to end while we watch rather than the reverse. If your team cannot add a new service to the platform without us in the room, the engagement is not finished.
Terraform, OpenTofu, Pulumi, CloudFormation or CDK?
We do not have a house tool. We have opinions about which one is wrong for which situation, which is more useful. The choice is far less consequential than the discipline around it, but it is genuinely hard to reverse once you have two years of state, so it deserves an hour of honest argument at the start rather than a default.
Terraform and OpenTofu
Terraform is the default recommendation for most teams, and the reason is boring: the provider ecosystem. Whatever you need to manage, from AWS and Azure to Cloudflare, Datadog, GitHub, PagerDuty and Snowflake, there is a maintained provider and somebody has already hit your problem on a public issue tracker. Hiring is easier too. In the Indian market specifically, Terraform experience is common and Pulumi experience is not, which matters when you are building a team rather than a proof of concept.
HCL's limitations are real and worth knowing before you commit. It is declarative and deliberately not a programming language, so anything that needs conditional logic across resources turns into nested ternaries and for expressions that read like line noise. There is no way to express "create this only if that other module produced an output", which pushes people toward count on a value that is not known until apply, and Terraform will refuse. Dynamic blocks help. They are also the fastest way to make a module unreadable.
The licence question needs a straight answer. HashiCorp relicensed Terraform from MPL 2.0 to the Business Source Licence in August 2023. OpenTofu forked from the last MPL release and is now a Linux Foundation project. For a team using Terraform to manage its own infrastructure, the BUSL restriction on competing products is not something you are likely to trip over, but that is a judgement for your legal team and not for us. Practically, the languages are the same and a migration in either direction is small today. It gets larger as you adopt vendor-platform features, which is the real lock-in rather than the licence.
Pulumi
Pulumi lets you write infrastructure in TypeScript, Python, Go, C# or Java, with the state model and the provider bridge borrowed from the same world Terraform lives in. When it fits, it fits very well. Loops, conditionals, classes, unit tests in a framework your developers already use, and IDE completion that actually works. If your platform team is a group of application engineers rather than dedicated infrastructure people, Pulumi removes a whole learning curve.
The trade-off is one people underestimate. A general-purpose language gives you the ability to write infrastructure code that nobody can review. Terraform's plan is trustworthy partly because HCL cannot do very much; once your stack has three layers of abstraction and a factory function, reading the diff no longer tells you what the code intended. We have picked up Pulumi codebases where the only way to know what a change did was to run it. That is a discipline problem rather than a tool problem, and the tool makes it easier to have.
Two practical notes. Pulumi's default state backend is its hosted service, and the self-managed backend over S3 or Azure Blob is well supported but slightly less loved, so check which you are getting. And secrets handling is genuinely better than Terraform's, because Pulumi encrypts secret values in state rather than writing them in the clear.
CloudFormation
If you are entirely on AWS, have no plans to be anywhere else, and want zero third-party tooling in the deployment path, CloudFormation is a reasonable answer that people dismiss too quickly. State is managed by AWS, so there is no bucket to secure and no lock to get stuck. Drift detection is built into the service. StackSets deploy across accounts and regions in an organisation without extra machinery. Change sets give you a plan equivalent.
Then there is the reason we rarely recommend it. YAML or JSON templates get long and repetitive fast, the intrinsic functions are awkward, and resource coverage lags new AWS features by weeks or months in a way the Terraform AWS provider generally does not. The failure mode that people remember is UPDATE_ROLLBACK_FAILED, where a stack gets stuck in a state that requires you to skip resources on the retry and sometimes to rebuild the stack. Nested stacks help with template size and add their own debugging misery. Per-stack resource limits push large estates into stack sprawl.
AWS CDK
CDK is CloudFormation with a real language on top, and the L2 constructs are the best argument for it. One line of CDK for an S3 bucket gives you encryption, blocked public access and sensible defaults that would be thirty lines of raw template. For a team building AWS-only serverless applications, the productivity difference is large enough to be worth the trade.
What you inherit, though, is every CloudFormation limitation underneath. A CDK deployment that fails rolls back as a CloudFormation stack, with the same stuck-state pathologies. The synthesized template is what actually runs, and reading it to debug an L3 construct's behaviour is a normal Tuesday. Construct library major versions have moved fast enough that upgrades have bitten teams. And cdk diff is useful but not the same guarantee as a Terraform plan, because logical ID changes can silently mean replacement.
Where the others fit
Crossplane is worth a look if you are already all-in on Kubernetes and want infrastructure reconciled by controllers rather than by a pipeline, with the same continuous-reconciliation model your workloads use. It is a genuinely different philosophy and it drags Kubernetes into being a hard dependency of your cloud provisioning, which is a bigger commitment than it looks.
Ansible is configuration management, not provisioning, and using it as a provisioner produces something that works and cannot be reasoned about. Where both are present, we usually draw the line at the instance boundary. Packer sits alongside all of these and stays useful: baking an AMI is still the cleanest way to make instance start-up fast and repeatable.
State: the Part That Actually Breaks
Terraform's state file is a mapping between the resources described in your code and the real objects in your cloud account. Lose it and Terraform no longer knows anything exists, so the next plan proposes to create everything. Corrupt it and you get worse: Terraform believes things that are false and acts on them. Nearly every genuinely frightening incident we have been called into on an infrastructure repository traces back to state.
The remote backend, and what "properly configured" means
Local state is fine for a single person learning. For a team it means the person who last ran apply is the only one who can run the next one. Remote state fixes that, but a bucket alone is not a backend. Versioning must be on, because the recovery path for a bad apply is restoring the previous state object and you only get that if the bucket kept it. Server-side encryption must be on, since state contains secrets. Public access must be blocked, and the bucket policy should be narrow enough that a developer's normal role cannot read production state at all.
On AWS the traditional pattern is S3 for the object plus a DynamoDB table for the lock. Terraform 1.10 introduced S3-native lockfile locking, which drops the DynamoDB dependency, and it is a real simplification when the whole team is on a version that supports it. The dangerous middle ground is a mixed team where some engineers and some CI runners use the new mechanism and others use the old one, because then two applies can hold different locks and neither is wrong. Pin the Terraform version in CI and in a version file in the repository, and check it.
Locking, and the lock that will not release
The lock stops two applies writing state simultaneously. It works. The failure you will meet is the stale lock: a CI runner gets killed mid-apply, the lock is never released, and every subsequent plan fails with a lock error naming a build that finished an hour ago.
There is a command for this, terraform force-unlock, and it is genuinely dangerous, which is why the runbook matters more than the command. Before force-unlocking anyone must establish that the original process is actually dead rather than slow. An apply that is still running against a large estate can look identical to a dead one from outside. We write that check into the runbook explicitly, with the exact console or API call to confirm the runner has terminated, because the 2am version of this decision is the one that goes wrong.
Splitting state, and the coupling it creates
Small state files are faster to plan, safer to apply and easier to reason about. They also need to talk to each other. The usual mechanism is a remote state data source, where the application state reads the VPC ID out of the network state, and that quietly makes the network state a published API. Change an output name and you break every consumer.
An alternative worth considering is looking values up by tag or by name through a data source against the provider rather than against another state file. It is slightly more fragile at runtime and considerably less coupled at design time. We generally reserve cross-state reads for values that genuinely cannot be discovered any other way, and treat the outputs of a shared state as a versioned contract with the same seriousness as an API.
Refactoring state without destroying things
Renaming a resource or moving it into a module changes its address, and Terraform's default reading of a changed address is destroy and create. On an RDS instance that is a very expensive typo. The moved block, available since Terraform 1.1, expresses the rename declaratively in code so the plan shows a move rather than a replacement, and it is reviewable in the pull request. It replaced the old terraform state mv workflow, which was imperative, unreviewed and ran on whichever laptop the engineer happened to be at.
We treat any plan containing an unexpected destroy on a stateful resource as a stop-work condition, not a thing to read past. prevent_destroy in a lifecycle block on databases, buckets, KMS keys and DNS zones makes that automatic: the apply fails rather than proceeding, and someone has to consciously remove the guard.
Module Design and Versioning
Modules are where infrastructure code either becomes a platform or becomes a maintenance burden with better folder structure. The difference is almost entirely about restraint.
Make modules small and honest about what they own
The pattern that fails is the mega-module: one module that takes forty variables and provisions a VPC, an EKS cluster, an RDS instance, a load balancer and the DNS records. It works for the first caller. The second caller needs one thing slightly different, so a boolean variable appears. Then another. Within a year the module has nineteen boolean flags, no combination of them is tested, and the module is harder to read than the resources it wraps.
Modules that survive tend to own one cohesive thing with a clear boundary: a network, a database with its subnet group and parameter group, a service with its task definition, load balancer target group and alarms. If you cannot describe what the module owns in one sentence without the word "and" appearing three times, it is two modules.
Version modules and consume them by version
A module referenced by git branch is a module that changes under you. A pipeline that was green yesterday fails today because someone merged to main in a repository you do not watch. Tag module releases with semantic versions, reference the tag in the source URL, and upgrade deliberately.
That also gives you a real answer to the question of how to roll out a change across thirty consumers. You do not. You publish v2.3.0, the consumers upgrade on their own schedule, and you keep a note of who is still on v1. Where a change genuinely must go everywhere at once, that is a coordinated migration with its own plan, not a merge to main.
Provider versions and the lock file
Pin provider versions with a pessimistic constraint and commit .terraform.lock.hcl. Providers ship breaking behaviour in minor releases more often than the version number suggests, and an unpinned provider means the plan you reviewed on Monday is not the plan that applies on Wednesday. The lock file is what makes CI and a developer laptop produce identical plans, and it needs the hashes for every platform your team and your runners use, which is what terraform providers lock with multiple -platform flags is for. Teams on Apple silicon with Linux CI hit this constantly.
count versus for_each, and why it matters more than it sounds
This is the single most common source of unexpected destruction we find in existing codebases. count addresses resources by numeric index. Remove the second item from a list of five subnets and Terraform does not delete the second one. It shifts everything down and proposes to modify or replace items two, three and four. With for_each over a map, each resource is keyed by a stable string, and removing one removes exactly one.
Use count for a genuine on-or-off toggle of a single resource. Use for_each for anything that is a collection. Converting an existing count resource to for_each needs moved blocks or state surgery to avoid a rebuild, so it is worth getting right early rather than fixing at scale.
Drift, and What to Do About It
Drift is the gap between what your code says and what your cloud account contains. It appears for ordinary reasons: someone fixed a production incident in the console at 3am, an autoscaling policy adjusted a value, a managed service updated a default, another team's automation added a tag, AWS changed a resource attribute in a way the provider now reports differently.
Pretending drift will not happen produces the worst version of it. Console access gets revoked entirely, the on-call engineer cannot fix an outage, an emergency break-glass role appears, and nobody records what was done with it. You end up with the same drift plus a governance story that will not survive an audit.
Detecting it
The mechanism is a scheduled terraform plan -refresh-only against each state, comparing reality with recorded state. Run it nightly, capture the output, and alert only when there is a difference. -detailed-exitcode gives you exit code 2 when there are changes, which makes the CI wiring trivial.
HCP Terraform has drift detection built in, and CloudFormation has native drift detection per stack. Both are convenient. Neither removes the need for a decision about what happens next, which is the part that gets skipped. Note that driftctl, which a lot of older blog posts recommend, was archived by its maintainers, so check the state of any tool you find in a 2022 article before building a process on it.
Reconciling it
Three responses, and the choice should be made by a human with context. Adopt the change: someone made a good decision in an incident, so bring it into code and merge it. Revert it: the change was wrong or unauthorised, so apply the code and let Terraform put it back. Or accept it permanently as unmanaged, using ignore_changes on the specific attribute, which is legitimate for things like autoscaling desired capacity or tags applied by a separate compliance tool.
Write the reason down whichever way you go. An ignore_changes block with no comment explaining why is a landmine for the engineer who finds it in eighteen months and cannot tell whether it is load-bearing.
Reducing how much drift happens at all
The lever that works is access. If nobody has standing write permission in the console, drift can only come from a deliberate escalation that is logged. Read-only by default, a break-glass role that requires approval and fires an alert when assumed, and a rule everyone has agreed to: anything done through break-glass gets reconciled into code within an agreed window, and the incident review checks that it did. What that window is depends on your change process and we set it with you rather than announcing it.
Plan Review as the Safety Gate
The plan is the single best safety feature in this whole discipline, and most teams waste it. A 900-line plan attached to a pull request that a reviewer scrolls past and approves is theatre. Making it real takes a bit of process design.
Make plans short enough to read
A plan is reviewable at roughly the length of a code diff. If yours is routinely hundreds of lines, the state is too big or the change is too big, and both are fixable. Splitting state by lifecycle is the structural fix. Requiring one logical change per pull request is the cultural one.
Put the plan where the review happens
Atlantis comments the plan directly on the pull request and applies on a comment command, which keeps the whole cycle in the code review tool your team already lives in. It is open source and you run it yourself. HCP Terraform, Spacelift and env0 give you the same loop as a managed service with policy enforcement, run history and better handling of concurrent runs, at a price. What none of them fix by themselves is a reviewer who does not know what to look for.
Give the reviewer a checklist worth having
The four questions we ask on every production plan. Does it destroy or replace anything, and if so, is that intentional and is the resource stateful? Does the count of changes match what the pull request description claims? Are there changes to IAM, security groups or network ACLs, which get a second reviewer regardless of size? And is there anything in the plan that the author did not mention, which usually means a provider upgrade or drift has come along for the ride.
That last one catches more real problems than the other three combined. A pull request that says "add a CloudWatch alarm" and produces a plan that also modifies eleven security group rules is telling you something, and it is almost never good news.
Separate who can plan from who can apply
Everyone can plan. Plans are read-only and running them freely is how people build confidence. Apply against production is a different permission, held by a named group, executed through the pipeline with the approval recorded. On an offshore engagement this split is what makes the rest of the model work, and we agree who holds apply rights on your side before the first commit rather than assuming.
Secrets in Infrastructure Code
There are two problems here that get discussed as one, and only the easy one usually gets solved.
Keeping secrets out of the repository
Straightforward. No .tfvars with real values in git, no default value on a password variable, credentials supplied to the pipeline through its own secret store, and a scanner such as gitleaks or trufflehog running on every commit and on the history. Where a value genuinely has to live in the repository, SOPS with a KMS key is a reasonable pattern because the encrypted file is diffable and the decryption is tied to an IAM role rather than a shared passphrase.
Better still, do not put the secret in Terraform at all. Have Terraform create the secret container and grant access to it, then have the application read the value at runtime. A database password that Terraform generates with random_password and writes to Secrets Manager never needs to be seen by a human or typed into a variable.
Keeping secrets out of state, which you mostly cannot
This is the part teams find out about late. Terraform writes resource attributes into state as plaintext. Marking a variable or an output sensitive stops it printing in the plan and in CI logs. It does not encrypt it in state. If you create an RDS instance with a password, that password is in your state file in the clear. Same for generated keys, certificate private keys and any secret read through a data source.
So the state backend is a secret store and gets the controls of one: encryption at rest with a customer-managed key, versioning, bucket policy that denies everything not on the allow list, access logging, and no path by which a developer's day-to-day role can read production state. Pulumi handles this better by encrypting secret values within state, and it is one of the stronger arguments in its favour.
Newer Terraform versions have been adding ephemeral values and write-only arguments intended to keep certain secrets out of state entirely. Provider and resource support is uneven, so check whether it covers the specific resource you care about rather than assuming the problem is solved. Until it demonstrably is, protect the state file.
The bit people forget: CI logs
A plan output containing a sensitive value is now in your CI system's log storage, which is often retained for months and often readable by more people than the state bucket is. Turn off plan output in logs for production pipelines or post only the summary, and check what your runner uploads as an artifact.
How Do You Run Dev, Staging and Production Without Copy-Pasting Everything?
This is the question that generates the most religious argument in Terraform teams, and the honest answer depends on where your account boundaries are.
Why we usually say directories, not workspaces
Terraform workspaces give you multiple state files behind one configuration. That sounds ideal until you notice what is shared. The backend configuration is shared, so all your workspaces put state in the same bucket, which means production state and dev state sit under the same access boundary. The provider configuration is shared, so pointing dev and production at different AWS accounts requires conditional logic in the provider block, which is exactly the sort of cleverness you do not want deciding which account an apply lands in.
A directory per environment, each with its own backend block, its own provider configuration and its own tfvars, gives you an account boundary that is visible in the file system. The duplication people object to is mostly eliminated by putting the actual resources in modules and leaving each environment directory as a thin composition: which modules, which versions, which values. Fifty lines per environment is normal and readable.
Where workspaces do earn their place
Ephemeral environments in a single account. A preview environment per pull request, a load-test environment that exists for six hours, a per-developer sandbox. All the same account, all the same credentials, all short-lived, differing only in a name prefix. That is the shape workspaces were designed for and they are good at it.
Terragrunt and the DRY question
Terragrunt exists mainly to remove the repetition in backend and provider configuration across many directories, and it does that well. It generates the backend block, handles dependencies between stacks, and lets you run a command across a tree of them. On an estate with forty state files it saves real effort.
The cost is a second tool with its own configuration language, its own failure modes and a smaller pool of engineers who know it, wrapped around the tool you are already using. We reach for it when the number of stacks makes hand-written backend blocks genuinely painful, which in practice means somewhere north of fifteen or twenty. Below that, a small amount of duplication is cheaper than the abstraction, and native Terraform has been closing the gap.
Keeping environments actually identical
Whichever layout you pick, the property that matters is that staging differs from production only in values you can enumerate: instance sizes, counts, domain names, and whichever features are deliberately disabled. If staging is missing a WAF, or uses a different load balancer type, or has one fewer availability zone, then testing there tells you less than you think. We write that difference list down as part of the handover, because an undocumented difference between environments is the root cause of an incident waiting to happen.
Policy as Code
Policy as code is how you stop having the same review comment for the fifth time. Anything a reviewer would reject on sight should be rejected by a machine before it reaches them, and reviewers should be spending their attention on intent rather than on whether encryption is enabled.
Scanners: Checkov, Trivy, tflint, Terrascan, KICS
Checkov ships with a large library of built-in rules across Terraform, CloudFormation, Kubernetes and more, and it runs on both source files and plan output. Running it against the plan rather than the source is the better setting, because it sees resolved values rather than variable references. Trivy absorbed tfsec and now covers infrastructure scanning alongside container and dependency scanning, which is convenient if you want one tool in the pipeline.
tflint does a different job worth having as well: provider-aware linting that catches invalid instance types, deprecated arguments and unused declarations before you spend a plan on them.
The mistake with all of them is enabling every rule on day one against an existing codebase. You get 400 findings, the team disables the check, and you are worse off than before. We baseline the existing state, fail the build only on new findings, and burn down the backlog by severity on an agreed schedule.
OPA and Conftest for rules only you have
Generic scanners cannot know that your organisation requires a cost-centre tag matching a specific pattern, or that only three instance families are approved, or that RDS instances in the payments account must be multi-AZ regardless of environment. Those rules go in Rego, evaluated by Conftest against the JSON plan, and they live in the same repository as the infrastructure so they are reviewable and versioned.
Rego takes a couple of days to become comfortable with and the error messages are not kind. It is worth it once you have more than a handful of custom rules, and it is overkill if you have two, which a shell script and jq against the plan JSON will handle.
Sentinel
Sentinel is HashiCorp's policy engine, integrated into the paid tiers of their platform. If you are already there it is well wired in, with policy sets applied per workspace and soft-mandatory levels that let a named person override with a recorded reason, which is a genuinely useful middle ground between advisory and blocking. If you are not on that platform, OPA gets you to the same place.
Cost as a policy check
Infracost against the plan puts an estimated monthly delta on the pull request. It will not be exact, and it does not need to be. What it does is turn a silent decision into a visible one, so that changing an instance family or adding a NAT gateway per availability zone shows up as a number in the review. You can also fail a build over a threshold, though a comment plus a required acknowledgement causes fewer arguments than a hard block.
Brownfield: Importing Infrastructure That Already Exists
Nearly every engagement we take is brownfield. There is an AWS account with six years of history, several people who have left, a naming convention that changed twice, and a shared belief that some of it can probably be deleted but nobody will be the one to try. Greenfield infrastructure as code is a pleasant exercise. Import is the real job.
What import actually does
terraform import, or the declarative import block introduced in Terraform 1.5, records an existing cloud object in state against an address in your code. It does not modify the resource. It does not write the code for you, though the -generate-config-out flag on the newer import block gets you a usable starting point that still needs editing.
The danger is the gap between the resource as it exists and the resource as your HCL describes it. Import a security group, forget a rule that was added by hand in 2022, and the very next apply removes that rule. In production. This is why the rule on every import batch is that plan must be empty before you move on, and empty means empty rather than "only a tag change".
How we sequence it
Read-only and inert things first: IAM policies, security groups, S3 buckets, DNS records. Then networking, which is high-value and low-churn. Then compute and load balancers. Databases last, with prevent_destroy in place before the import runs, not after.
Batches stay small, in the range of five to fifteen resources, because a batch that produces a non-empty plan needs to be debuggable and a batch of eighty is not. Each batch is its own pull request with the plan output attached, which also gives your team a readable history of what came under management when.
Tools that help, and their limits
Former2 generates Terraform from existing AWS resources through the console or a CLI, and is a decent starting point for bulk work. Terraformer covers multiple providers and can generate both configuration and state. AWS has its own template-from-resources capability on the CloudFormation side.
All of them produce code you would not have written. Expect hardcoded values where you want variables, every optional attribute set explicitly, no module structure, and resource names derived from cloud IDs. Treat the output as a first draft that saves typing rather than as a result. Generated code that nobody has read is a liability sitting in your repository looking authoritative.
What to leave unmanaged, deliberately
Not everything should come under code. Resources created and owned by another team's automation, anything managed by a vendor's integration, and legacy things scheduled for decommission are usually better left alone with a documented note than half-imported. A written list of deliberately unmanaged resources is a deliverable, and it is what stops the next engineer assuming the import was simply incomplete.
Testing Infrastructure Code
Infrastructure testing is harder than application testing for one structural reason: there is no cheap way to run the real thing. You can unit test a function in milliseconds. Verifying that a module produces a working EKS cluster takes fifteen minutes and costs money. So the useful strategy is layered, with most of the checking done at the cheap end.
Layer one: static analysis, seconds
terraform fmt -check, terraform validate, tflint and a security scanner. Runs on every commit, catches formatting, invalid references, invalid instance types, deprecated syntax and the security baseline. Cheap enough that there is no argument for skipping it.
Layer two: plan assertions, a minute
Generate the plan, convert it to JSON with terraform show -json, and assert against it. Conftest and Rego, or a Python script, or Terraform's own check blocks. This layer catches a surprising amount: that the change touches only the resource types you expect, that no deletion appears in a production plan without an override label on the pull request, that the resource count moved by the amount the pull request claims.
Nothing is created, so it is fast and free, and it is the layer most teams have not built. If you only add one thing after reading this page, add this one.
Layer three: Terraform's native test framework, minutes
Since version 1.6, Terraform has had .tftest.hcl files that define test runs against a module, in HCL, with assertions on outputs and resource attributes. Runs can be plan-only, which is fast, or apply-and-destroy, which is real. For module authors this removed most of the reason to reach for a Go framework, and the barrier to writing a test dropped a lot because it is the same language the module is in.
Layer four: Terratest, tens of minutes
Terratest is a Go library from Gruntwork that provisions real infrastructure, runs assertions against it, and tears it down. Its advantage over the native framework is that you are in Go, so you can make an HTTP request to the load balancer you just created, SSH to an instance, query a database, or call a cloud API to check something Terraform does not expose. That end-to-end verification is the thing nothing else gives you.
It is also slow, costs real money in a test account, and leaks resources when a run dies badly, so budget for a cleanup job. We use it for shared modules that many teams depend on and for anything where a mistake is expensive, and we do not use it for a module that provisions three CloudWatch alarms.
What to test in a test account
Run provisioning tests in a dedicated account with a hard budget alarm and an automated sweeper that deletes anything older than a day. Tag everything a test creates so the sweeper can find it. This sounds like over-engineering until the first time a failed Terratest run leaves a NAT gateway and three RDS instances running through a weekend.
Three Situations We Get Called Into
These are composites drawn from the shape of the work rather than named accounts, and the details are typical rather than specific to any one client.
The startup that outgrew its console
A Series A company runs everything in one AWS account, built by two engineers over three years, entirely through the console. It works. Then a customer asks for a security questionnaire, an auditor asks how changes are approved, and the answer is that a person clicks. Meanwhile a new environment for a large prospect has been quoted at two weeks and everyone privately knows it is six.
The work here is import-first. Inventory the account, find that there are around 300 managed-worthy resources of which maybe 40 matter, import them in batches, and separate networking from applications so a plan is readable. Then a second account gets created from the same modules to prove the code actually reproduces the environment, which is the moment everyone stops arguing about whether this was worth it. The reproduction attempt always finds three things that existed only in the original account and were never in code anywhere.
The enterprise with fourteen Terraform repositories and no standard
A larger organisation where each team adopted Terraform independently. Six versions in use, four different state backend patterns, three module libraries with overlapping purposes, no policy enforcement, and a platform team that has been asked to bring order without the authority to mandate anything.
Tooling is not the constraint here. The path that works is a golden-path module library that is genuinely better than what teams have, with tested modules for the five things everyone builds, plus a shared pipeline that gives them plan-on-pull-request and policy checks for free. Adoption is voluntary and it happens anyway, because the alternative is maintaining their own. We usually pair with two or three engineers from different teams during this so the library is not seen as something imposed from outside, and version pinning gets settled early since a shared library nobody can upgrade on their own schedule will be abandoned.
The team that inherited an estate after the person left
The uncomfortable one. One engineer built the whole platform, left, and the repository is a single 4,000-line root module with a state file nobody has touched in five months because the last plan showed 60 changes and no one could tell which were safe.
The first task is not refactoring. It is establishing what the 60 changes are: some are drift from console fixes, some are provider version differences, some are genuinely stale code. That triage produces three lists, and each gets a different treatment: adopt into code, revert, or ignore with a comment. Only when a plan is clean does splitting the root module begin, using moved blocks so each split is a reviewable pull request with a plan showing zero destroys. It is slow work and it is the only version that does not end in an outage.
How We Run This From India
Infrastructure work is a strange fit for offshore delivery in one respect and an unusually good fit in another. The strange part is that it is high-consequence, so the trust question is sharper than it is for feature work. The good part is that the whole discipline is already written down: a plan is a document, a module is reviewable, a policy check is a file. There is far less tacit knowledge here than in most engineering, which is why the asynchronous model works.
The overlap window, without the marketing
A standard Indian working day of 09:30 to 18:30 IST maps to roughly 05:00 to 14:00 in London, so a UK team gets about five hours of genuine overlap without anyone changing their hours. Sydney gets around three hours in their afternoon. US Eastern gets almost nothing: 18:30 IST is 09:00 in New York, so on a standard day the overlap is the last half hour, which is not a working relationship.
For US clients the fix is a shifted team, typically starting early afternoon IST and finishing late evening, which buys three to four hours against a New York morning. That is a real cost paid by real people and it should be priced and staffed as one rather than waved at. US Pacific is harder still and usually needs a deliberate split: a couple of hours of live overlap for the decisions that need a conversation, and everything else handled in writing. We agree the specific window with you before the engagement starts, and it goes in writing rather than being assumed.
Nobody should tell you offshore infrastructure work gives you 24 hour coverage for free. Round-the-clock coverage means a shift rota, which means more engineers, handover discipline between shifts, and a runbook good enough that the person taking over at 2am has never seen the alert before and can still act on it. It is buildable and it is not a side effect of a timezone.
What the daily rhythm looks like
Written standup posted in your Slack before your day starts, covering what moved, what is blocked and what needs a decision from your side. Pull requests raised during the Indian day with the plan attached and a written summary of what it changes. A live call in the overlap window for the things that genuinely need talking through, which for infrastructure work is usually architecture decisions and incident review rather than status.
The habit we push hardest is that anything needing your input is asked as a question with options and a recommendation, not as an open-ended blocker. A message saying "should we split the network state?" costs you a day. A message saying "we propose splitting the network state this way, here are the two alternatives and why we rejected them, we will proceed Thursday unless you object" costs you five minutes and keeps things moving.
Access, and who can actually change your infrastructure
The default arrangement we push for is named IAM identities in your accounts for each engineer, not a shared credential, with permissions scoped to what the current phase needs. Plan permissions broadly, apply permissions narrowly. Production apply either goes through your pipeline with an approver on your side, or is held by a named person, and that is your decision rather than ours.
Data residency is usually less fraught for infrastructure work than for application or data work, because Terraform state describes resources rather than containing your customers' records. State does contain secrets, though, so it stays in your account and your region. Whatever the arrangement, the specifics of access, confidentiality and IP assignment belong in the agreements signed before work starts rather than on a web page.
The engineers and how they are assessed
The Indian market has a deep pool of cloud engineers, considerably deeper on AWS and Azure than on GCP, and much deeper on Terraform than on Pulumi. That is a real constraint on staffing and we would rather say so than discover it in week three. If you want a Pulumi codebase in Go, the search takes longer and the shortlist is shorter.
Our own screening for this work is practical rather than certificate-driven. We give candidates a broken state situation and watch how they reason about it, because knowing that force-unlock exists is worth less than knowing when not to run it. We look at whether they read a plan carefully or scroll to the summary. And we check written English specifically, since on this kind of engagement most communication with you will be written, and a pull request description that does not explain the change is a real problem regardless of how good the HCL is.
What Goes Wrong, and How We Handle It
The honest section. These are the things that derail infrastructure as code engagements, offshore or otherwise.
The estate is bigger than anyone thought
The inventory phase routinely finds two or three times the resources people expected, plus accounts nobody remembered. This is the main reason we do not quote a full migration before the assessment. Anyone who gives you a fixed price for importing an estate they have not counted is guessing, and the guess will be resolved in their favour or as a fight.
A provider upgrade changes behaviour
You upgrade the AWS provider for a feature you need and an unrelated resource now plans a replacement because the provider changed how it reads an attribute. Handled by upgrading providers in their own pull request with nothing else in it, reading the changelog rather than skimming it, and having a plan-diff review as the acceptance criterion. Never bundle a provider upgrade with a feature change.
The team does not adopt it
The failure that wastes the most money. A platform gets built, it is good, and six months later half the team is still using the console because the code path is slower for what they need to do. Usually this means the golden path did not cover a common case, or the pipeline is slow enough to be annoying, or nobody was trained properly. We treat adoption as a deliverable with a measure attached: what percentage of changes in a given month went through the pipeline. If that number is not moving, something in the design is wrong and more documentation will not fix it.
Someone applies from a laptop during an incident
It will happen. The response that works is a defined break-glass path with logging and a reconciliation requirement, rather than a rule everyone breaks quietly. The version that does not work is pretending nobody has console access.
Knowledge concentrates in one person again
Infrastructure work concentrates knowledge more than most engineering, because the person who designed the state layout holds a model of it that is not fully written down. We counter it with rotation on the pipeline work, architecture notes explaining why rather than what, and a documentation test: someone who was not involved makes a change following the runbook, and where they get stuck is what needs writing. Handover terms and how transitions are managed are set in the agreement before work starts.
Cloud costs go up before they go down
Worth saying because it surprises people. Test accounts, duplicate environments during migration and shadow resources during import all cost money, and the savings from right-sizing and cleanup arrive later. Budget for a bump in months one and two rather than being ambushed by it.
Engagement Models
Three shapes, and the right one depends mostly on whether you have a platform team already and what happens after the work lands.
Scoped project
A defined outcome with a defined end: import this account, build the module library, replace CloudFormation with Terraform, stand up the pipeline and policy gates. Works when the goal is describable in a sentence and your team will own the result. We scope it after the assessment rather than before, because the resource inventory is what makes an estimate honest.
Dedicated team
Engineers working as part of your team, in your tools, on your board, for an ongoing period. This suits organisations building a platform rather than completing a project, where the work is continuous and priorities shift week to week. It is also the model where the overlap window matters most, so it is worth settling that before anything else.
Ongoing platform support
Once the platform exists it needs maintenance that is real but not full-time: provider and version upgrades, module updates, drift review, policy tuning, new module requests, and being available when a plan does something alarming. Retained capacity for that is often the sensible follow-on to a scoped project, and it keeps the people who built the thing reachable.
Commercial terms, notice arrangements and how a team is scaled or wound down are agreed in the contract before work starts. We are not going to invent them on a marketing page.
Where This Sits Alongside Our Other Work
Infrastructure code is one layer of a platform, and it is not the layer most people meet first. The pipeline that actually runs the applies, and the application deployments alongside them, is covered by our CI/CD pipeline services in India. The cluster that a lot of this code exists to provision has its own set of problems once workloads land on it, which is Kubernetes services in India.
If the open question is what the estate should look like rather than how to express it in HCL, start with cloud architecture services in India, because writing modules for a topology you are about to change is wasted effort. Once the platform is running, keeping it healthy is a separate discipline again: monitoring, on-call, error budgets and incident review sit under site reliability engineering services in India.
And if what you actually want is engineers embedded in your team rather than a scoped piece of work, hire DevOps developers in India explains that model, including how screening and the overlap window work.
Frequently Asked Questions
Should we standardise on Terraform or OpenTofu?
HashiCorp moved Terraform from the Mozilla Public Licence to the Business Source Licence in August 2023, and OpenTofu was forked from the last MPL version and now sits under the Linux Foundation. For most teams the practical answer is that the language is the same and switching either way is a small job today. It gets harder the more provider-specific and vendor-platform features you adopt. Read the current licence text against your own policy rather than taking anyone's summary, ours included.
Where should Terraform state live, and do we still need a DynamoDB lock table?
State belongs in a versioned, encrypted remote backend with locking, never in a git repository and never on a laptop. On AWS that has traditionally meant an S3 bucket with versioning plus a DynamoDB table for the lock. Terraform 1.10 and later support S3-native lockfile locking, which removes the extra table if every engineer and every CI runner is on a new enough version. Mixed versions across a team is how you get two people writing state at once.
Can you bring our existing cloud account under Terraform without downtime?
Yes, and it is most of the work in a brownfield engagement. Import moves a resource into state without touching the running resource, so nothing restarts if the code matches reality. The risk is the mismatch: if your HCL omits a tag or a rule that exists in the account, the next plan proposes to remove it. We import in small batches, run plan until it is empty, and put prevent_destroy on anything stateful before starting.
Terraform workspaces or a separate directory per environment?
Directories, in nearly every case where the environments live in different cloud accounts. Workspaces share one backend configuration and one provider configuration, so production and dev end up in the same state bucket and often the same credentials path, which is exactly the blast radius you were trying to avoid. Workspaces are a reasonable fit for short-lived preview environments inside a single account, and a poor fit for the dev, staging, production split.
How do you keep secrets out of infrastructure code and out of state?
Two separate problems. Keeping them out of the repository is easy: no committed tfvars, secrets read at apply time from Vault, AWS Secrets Manager or SSM Parameter Store, and a scanner such as gitleaks in CI. Keeping them out of state is harder, because Terraform writes resource attributes to state in plaintext even when marked sensitive. So the state backend is treated as a secret store: encrypted, versioned, tightly scoped IAM, access logged.
What does testing infrastructure code actually mean?
Four layers, in ascending cost. Static analysis with tflint, Checkov or Trivy runs in seconds and catches open security groups and unencrypted volumes. Plan assertions check the plan JSON against rules before anything is created. Terraform's native test framework spins up real resources against a module and asserts on outputs. Terratest in Go goes further and lets you call the deployed thing. Most teams need the first two and one module-level suite on shared modules.
How does plan review work when your engineers are in India and ours are in New York?
The plan output is the review artifact, which suits a written-first, asynchronous workflow better than most engineering work does. Atlantis or a pipeline posts the plan on the pull request, an engineer in India writes the summary of what changes and why, and your reviewer approves during their morning. Applies to production are gated on that approval. We agree the overlap window and who holds apply rights with you before the first commit.
Our infrastructure drifts because people fix things in the console. How do you stop that?
You cannot stop it with tooling alone, and teams that try usually end up with a break-glass path nobody documents. The workable version is a scheduled refresh-only plan that reports differences daily, a rule that console changes during an incident are allowed but must be reconciled within an agreed window, and IAM that removes standing write access from humans so the console route requires deliberate escalation.