Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate6 min readUpdated
On this page

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.
  • plan in CI on every pull request; apply only from the main branch.

State

backend.tf
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

modules/spring-service/main.tf
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 }
environments/production/main.tf
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 owns infrastructure that changes rarely. Application deployment belongs to a tool designed for frequent, safe rollouts.

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

.github/workflows/terraform.yml
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 tfplan

Posting 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

index.ts
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?
State maps your configuration to real resource IDs. Two concurrent applies against the same state can interleave writes and corrupt it, at which point Terraform has lost track of resources that still exist and bills continue. A DynamoDB table or equivalent lock makes the second apply wait.
Should application deploys go through Terraform?
No. Terraform is good at long-lived infrastructure that changes rarely and bad at frequent application rollouts — it has no concept of a canary and its state model fights fast iteration. Provision the cluster and managed services with Terraform, deploy applications with Helm or Argo CD.
Terraform or Pulumi?
Terraform for its ecosystem — more providers, more modules, more people who know it. Pulumi when infrastructure genuinely needs loops, conditionals and abstractions that HCL expresses awkwardly, and when your team would rather test infrastructure with the same tools as application code.

Related tutorials