+1 (726) 227-3497

Amazon Redshift Serverless as Code: Terraform for Namespaces, Workgroups and Environment Promotion

Almost every Redshift environment we inherit was created by hand. Someone clicked through the console in 2022, named the workgroup default, set base RPUs to 128 "just to see", and nobody has been able to reproduce that configuration in staging since. The database objects are usually versioned — dbt or Flyway or at least a Git folder of DDL — but the warehouse itself is not, so dev, staging and prod drift apart and every change is a manual ticket.

This tutorial covers putting Amazon Redshift Serverless under Terraform: namespace and workgroup, networking, IAM roles for COPY and Spectrum, scheduled scaling of RPUs, and the parts of Redshift that Terraform should deliberately not own. Everything here works the same way with CloudFormation or CDK; the resource names differ, the boundaries do not.

The two resources everything hangs off

Redshift Serverless splits storage identity from compute. A namespace owns the database, its admin credentials, its IAM role associations and its snapshots. A workgroup owns compute: RPU capacity, the VPC subnets and security groups, the endpoint, and the parameter settings. One namespace can have several workgroups — that is how you isolate ELT from BI without copying data.

resource "aws_redshiftserverless_namespace" "warehouse" {
  namespace_name      = "wh-${var.env}"
  db_name             = "warehouse"
  admin_username      = "wh_admin"
  manage_admin_password = true   # AWS-managed secret in Secrets Manager

  iam_roles            = [aws_iam_role.redshift_data_access.arn]
  default_iam_role_arn = aws_iam_role.redshift_data_access.arn

  kms_key_id = aws_kms_key.warehouse.arn

  log_exports = ["userlog", "connectionlog", "useractivitylog"]

  tags = local.tags
}

Two things in there matter more than the rest.

manage_admin_password = true hands credential generation and rotation to AWS and keeps the password out of your state file. The alternative, admin_user_password, writes a plaintext secret into terraform.tfstate forever — treat that as a finding, not a style preference.

log_exports is the cheapest audit decision you will make. Turning it on at creation time means you have connection and user-activity history the first time someone asks who ran a DROP.

Now the compute:

resource "aws_redshiftserverless_workgroup" "elt" {
  namespace_name = aws_redshiftserverless_namespace.warehouse.namespace_name
  workgroup_name = "wg-elt-${var.env}"

  base_capacity        = var.elt_base_rpu   # 32 in dev, 128 in prod
  max_capacity         = var.elt_max_rpu    # ceiling on burst
  publicly_accessible  = false
  enhanced_vpc_routing = true

  subnet_ids         = var.private_subnet_ids        # 3 AZs, /24 or larger
  security_group_ids = [aws_security_group.redshift.id]

  config_parameter {
    parameter_key   = "auto_mv"
    parameter_value = "true"
  }
  config_parameter {
    parameter_key   = "enable_case_sensitive_identifier"
    parameter_value = "false"
  }
  config_parameter {
    parameter_key   = "require_ssl"
    parameter_value = "true"
  }

  tags = local.tags
}

Gotchas we hit on real projects:

  • Subnets need room. Redshift Serverless wants at least three subnets in three Availability Zones with enough free IPs. A workgroup that fails to create with an opaque capacity error is usually a /28 subnet, not a quota problem.
  • base_capacity changes are in-place but disruptive-ish. Terraform will apply the change without recreating the workgroup, but queries in flight can be affected. Treat it as a maintenance-window change in prod.
  • Changing namespace_name or workgroup_name destroys and recreates. Renaming a namespace destroys the database. Pick names once, with the environment suffix already in them.
  • enable_case_sensitive_identifier is one of those settings that silently breaks a BI tool six months later. Set it explicitly rather than inheriting whatever the default is that quarter.

IAM: one role, scoped to what the warehouse actually reads

The role attached to the namespace is what COPY, UNLOAD, Spectrum, external Iceberg tables and Redshift ML assume. The usual failure mode is AmazonS3FullAccess plus a shrug.

data "aws_iam_policy_document" "assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["redshift.amazonaws.com", "redshift-serverless.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "redshift_data_access" {
  name               = "redshift-data-access-${var.env}"
  assume_role_policy = data.aws_iam_policy_document.assume.json
}

data "aws_iam_policy_document" "lake" {
  statement {
    actions   = ["s3:GetObject", "s3:ListBucket"]
    resources = [
      "arn:aws:s3:::${var.lake_bucket}",
      "arn:aws:s3:::${var.lake_bucket}/raw/*",
      "arn:aws:s3:::${var.lake_bucket}/curated/*",
    ]
  }
  statement {                                  # UNLOAD targets only
    actions   = ["s3:PutObject", "s3:DeleteObject"]
    resources = ["arn:aws:s3:::${var.unload_bucket}/*"]
  }
  statement {                                  # Glue catalog for Spectrum / Iceberg
    actions   = ["glue:GetDatabase*", "glue:GetTable*", "glue:GetPartition*"]
    resources = ["*"]
  }
}

Attach that policy to the role and stop there. If a later engagement needs Redshift ML or federated queries, add a second, separately named policy so the blast radius of each capability is legible in Git.

Read paths and write paths in different statements is not pedantry — it is the difference between a bad UNLOAD overwriting a curated prefix and failing loudly.

Scheduled capacity, because dev does not need 128 RPUs at 3am

Serverless bills for RPU-seconds while queries run, so an idle workgroup is nearly free. What is not free is a large base_capacity during a nightly batch that would run fine on half of it. If your load is predictable, schedule it:

resource "aws_redshiftserverless_scheduled_action" "scale_up_for_batch" {
  name         = "wg-elt-${var.env}-scale-up"
  namespace_name = aws_redshiftserverless_namespace.warehouse.namespace_name
  schedule { cron = "cron(30 1 * * ? *)" }
  role_arn     = aws_iam_role.scheduler.arn

  target_action {
    update_workgroup {
      workgroup_name = aws_redshiftserverless_workgroup.elt.workgroup_name
      base_capacity  = var.elt_batch_rpu
    }
  }
}

Pair it with a scale-down action after the batch window. And set max_capacity on every workgroup: it is the only hard stop between a runaway cross join and a bill you have to explain. Our 2026 Redshift cost-tuning checklist covers the rest of the levers.

What Terraform should not own

This is the part teams get wrong, and it costs them a weekend.

Do not manage users, groups, roles, schemas or grants in Terraform. The postgresql provider technically can, but Terraform's model — desired state, destroy on removal — is a poor fit for database privileges. Removing a postgresql_grant block from a module and having an apply revoke access from a live BI service account during business hours is a genuinely bad afternoon. Put DDL and RBAC in migrations that run forward only, versioned alongside your dbt project. See our tutorial on RBAC, row-level security and dynamic data masking for how we structure those roles.

Do not manage tables. Sort keys, distribution styles and Automatic Table Optimization decisions belong to the transformation tool, not the infrastructure tool.

Do not let Terraform own snapshots that exist for recovery. A recovery point referenced from state can be destroyed by a careless terraform destroy. Manage the policy (aws_redshiftserverless_snapshot_schedule-style automation and cross-Region copy configuration) and let the snapshots themselves be data. Our backup and disaster recovery post covers the RPO/RTO side.

The clean split: Terraform owns the warehouse; migrations own the database.

Modules and environment promotion

Wrap the resources above in one module and instantiate it per environment:

modules/redshift-serverless/
  main.tf variables.tf outputs.tf
envs/dev/main.tf        # base_capacity = 32,  max = 64,   log retention 7d
envs/staging/main.tf    # base_capacity = 32,  max = 128
envs/prod/main.tf       # base_capacity = 128, max = 512,  prevent_destroy

Two guardrails that have paid for themselves repeatedly:

lifecycle {
  prevent_destroy = true          # on the prod namespace, always
}

and a CI pipeline that runs terraform plan on every pull request and posts the plan, so "this recreates the namespace" is caught by a reviewer rather than by an apply. Any plan that shows namespace_name or workgroup_name changing, or the namespace being replaced, is a stop-the-line event.

For outputs, export the endpoint and the Secrets Manager ARN so downstream tooling never hardcodes a hostname:

output "elt_endpoint" {
  value = aws_redshiftserverless_workgroup.elt.endpoint[0].address
}
output "admin_secret_arn" {
  value = aws_redshiftserverless_namespace.warehouse.admin_password_secret_arn
}

Importing the cluster you already have

You do not have to start over. Terraform can adopt existing infrastructure:

terraform import 'module.redshift.aws_redshiftserverless_namespace.warehouse' wh-prod
terraform import 'module.redshift.aws_redshiftserverless_workgroup.elt'      wg-elt-prod
terraform plan   # goal: no changes

Iterate on the HCL until plan is empty. That empty plan is the moment your production warehouse becomes reproducible — and it is also when you usually discover the three config parameters nobody knew were set.

If you are still on provisioned RA3, the equivalent resource is aws_redshift_cluster, and the same boundaries apply. Whether you should be on RA3 or Serverless at all is a separate question, which we work through in RA3 vs Serverless in 2026.


Codifying a warehouse that grew by hand is a normal part of the Redshift Administration and AWS Ecosystem Development work we do — usually alongside a cost review, because the first honest terraform plan tends to surface a couple of oversized workgroups. If your Redshift environment cannot be rebuilt from a repo today, get in touch and we will scope it.