Infrastructure as Code for Java Apps
Provisioning the infrastructure a Java service needs: Terraform state and locking, reusable modules, managed databases and brokers, and where Pulumi fits.
Infrastructure defined in code is reviewable, reproducible and auditable. The parts that go wrong are almost always state management and the boundary between infrastructure and application deployment.
Key Takeaways
- Remote state with locking is the first thing to set up, before any real resource exists.
- Modules turn a repeated pattern into one reviewed implementation.
- Terraform provisions long-lived infrastructure; Helm or Argo CD deploys applications.
- Never commit state — it contains resource IDs and often secrets in plain text.
planin CI on every pull request;applyonly from the main branch.
State
terraform {
required_version = ">= 1.9"
backend "s3" {
bucket = "acme-terraform-state"
key = "production/order-service/terraform.tfstate"
region = "eu-west-1"
encrypt = true
# Without a lock, two concurrent applies can corrupt state and leave
# Terraform unaware of resources that still exist and still cost money.
dynamodb_table = "terraform-locks"
}
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.60" }
}
}State is the file that maps your HCL to real resource identifiers. Losing it means Terraform no longer knows what it created, and the recovery is importing every resource by hand. Three rules follow: store it remotely, enable versioning on the bucket so you can recover a bad write, and never commit it to Git — it contains resource IDs and frequently secrets in plain text.
Split state by blast radius. One enormous state file means every change plans against every resource, which is slow and makes a mistake potentially catastrophic. A file per environment per component is a reasonable default.
A module
variable "name" { type = string }
variable "environment" { type = string }
variable "db_instance_class" {
type = string
default = "db.t4g.medium"
validation {
condition = can(regex("^db\\.", var.db_instance_class))
error_message = "must be a valid RDS instance class"
}
}
resource "aws_db_instance" "main" {
identifier = "${var.name}-${var.environment}"
engine = "postgres"
engine_version = "16.3"
instance_class = var.db_instance_class
allocated_storage = 100
max_allocated_storage = 500 # storage autoscaling
storage_encrypted = true
db_name = replace(var.name, "-", "_")
username = "app"
# Managed by AWS, rotated automatically, never in state as plaintext.
manage_master_user_password = true
backup_retention_period = var.environment == "production" ? 30 : 7
# Point-in-time recovery is only useful if you have actually tested a restore.
deletion_protection = var.environment == "production"
skip_final_snapshot = var.environment != "production"
multi_az = var.environment == "production"
vpc_security_group_ids = [aws_security_group.db.id]
tags = local.common_tags
}
resource "aws_msk_cluster" "events" {
count = var.needs_kafka ? 1 : 0
cluster_name = "${var.name}-${var.environment}"
kafka_version = "3.7.0"
number_of_broker_nodes = var.environment == "production" ? 3 : 2
# ...
}
output "db_endpoint" { value = aws_db_instance.main.endpoint }
output "db_secret_arn" { value = aws_db_instance.main.master_user_secret[0].secret_arn }module "order_service" {
source = "../../modules/spring-service"
name = "order-service"
environment = "production"
db_instance_class = "db.r6g.xlarge"
needs_kafka = true
}manage_master_user_password is worth adopting wherever the provider supports it. A password
generated by Terraform ends up in state in plain text; a managed one lives in Secrets Manager, rotates
on a schedule, and the application reads it at runtime.
Modules earn their keep when a pattern repeats. A module used once is indirection with no payoff; one used by fifteen services means a security improvement is made once and applies everywhere.
Where Terraform stops
Terraform can manage Kubernetes resources through its provider, and doing so is usually a mistake. It
has no concept of a canary or a rollback, its state model fights frequent change, and a terraform apply that touches a Deployment is a far blunter instrument than a Helm upgrade or an Argo sync.
Pass infrastructure outputs into the application layer instead — the database endpoint into a ConfigMap, the secret ARN into an External Secret — and let each tool do what it is good at.
The pipeline
jobs:
plan:
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read, pull-requests: write }
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check -recursive
- run: terraform init
- run: terraform validate
# Static analysis for insecure configuration — public buckets,
# unencrypted volumes, over-permissive security groups.
- uses: bridgecrewio/checkov-action@master
with: { directory: ., framework: terraform }
- run: terraform plan -out=tfplan -input=false
- name: Post plan to the pull request
run: terraform show -no-color tfplan >> $GITHUB_STEP_SUMMARY
apply:
needs: plan
if: github.ref == 'refs/heads/main'
environment: production # requires manual approval
steps:
- run: terraform apply -auto-approve -input=false tfplanPosting the plan onto the pull request is what makes infrastructure review real. A reviewer looking at
HCL has to simulate Terraform mentally; a reviewer looking at "will destroy aws_db_instance.main"
does not.
Checkov catches the misconfigurations that cause incidents — a security group open to the world, an unencrypted volume, a bucket with public access — before they reach an account.
One detail there matters more than it appears: apply consumes the exact tfplan file the plan job
produced, rather than computing a fresh one. Re-planning at apply time means the change a reviewer
approved and the change that actually executes are two different things, and that gap is exactly where
a concurrently merged pull request does its damage.
Drift
Real infrastructure drifts. Somebody fixes something in the console at 3am and forgets to bring it
back into code, and the next apply silently reverts their fix or fails confusingly.
A scheduled terraform plan that alerts on any non-empty diff turns that into a visible, dated
finding. Run it nightly, and treat a persistent drift as a bug — either the console change should be
codified, or it should not have been made.
Pulumi
const db = new aws.rds.Instance("orders", {
engine: "postgres",
engineVersion: "16.3",
instanceClass: env === "production" ? "db.r6g.xlarge" : "db.t4g.medium",
allocatedStorage: 100,
storageEncrypted: true,
multiAz: env === "production",
});
export const dbEndpoint = db.endpoint;Pulumi uses real programming languages, which means loops, conditionals, functions and unit tests work the way you already know. For infrastructure with genuine logic — generating per-tenant resources, complex conditional topologies — it is considerably more pleasant than HCL.
Terraform's advantage remains ecosystem: more providers, far more community modules, and a much larger pool of people who already know it. For most teams that is the deciding factor.
What to take away
Set up remote state with locking and versioning before anything else. Split state by blast radius, build modules for repeated patterns, and let Terraform stop at the cluster boundary. Post plans onto pull requests, scan with Checkov, and run a scheduled drift check so console changes do not accumulate silently.
Frequently Asked Questions
Why does remote state need locking?
Should application deploys go through Terraform?
Terraform or Pulumi?
Related tutorials
- Kubernetes Deployment StrategiesShipping without downtime: rolling update mechanics, blue-green switching, canary with automated analysis, and GitOps reconciliation with Argo CD.
- Production Observability — Full StackAssembling a production observability stack: the OTel agent and collector pipelines, Mimir, Loki and Tempo, alerting strategy that avoids fatigue, and runbooks that get used.
- Docker in CI/CD & ProductionContainer builds that are fast and trustworthy: BuildKit cache and secret mounts, tagging strategy, registry choice, vulnerability scanning, SBOM generation and image signing.
- Production-Grade Application ConfigurationConfiguration that survives production: the 12-factor principles applied to Java, property precedence, fail-fast validation, feature flags, and graceful shutdown done properly.