IaC AWS

Terraform Module: AWS Redshift — a hardened, var-driven data warehouse you can ship in every account

Quick take — A reusable Terraform module for AWS Redshift: provisioned clusters wired to a subnet group and parameter group, KMS encryption, IAM role attachment, audit logging and automated snapshots — production-ready for hashicorp/aws ~> 5.0. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "redshift" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-redshift?ref=v1.0.0"

  cluster_identifier     = "..."           # Unique cluster identifier; also prefixes the subnet/par…
  subnet_ids             = ["...", "..."]  # Private subnet IDs (≥2 AZs) for the subnet group.
  vpc_security_group_ids = ["...", "..."]  # Security groups controlling inbound access.
  kms_key_arn            = "..."           # KMS key ARN for at-rest encryption (always enabled).
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon Redshift is AWS’s petabyte-scale, columnar, MPP (massively parallel processing) data warehouse. You load structured and semi-structured data into it from S3, Kinesis, DMS or Glue, and run analytical SQL — large aggregations, window functions, joins across billions of rows — at a speed that transactional databases like RDS were never built for. A Redshift provisioned cluster is a fixed set of compute nodes (a leader node plus one or more compute nodes of a node type such as ra3.xlplus or dc2.large), where RA3 nodes separate compute from storage via Redshift Managed Storage (RMS) so you scale the two independently.

The trouble is that a correct, safe Redshift cluster is never a single resource. To stand one up properly you also need a cluster subnet group (which private subnets the cluster lives in), a parameter group (WLM/queue config, require_ssl, enable_user_activity_logging), a KMS key for at-rest encryption, an IAM role attached so the cluster can COPY/UNLOAD against S3 and call Glue, audit logging to S3 or CloudWatch, and tuned snapshot/maintenance windows. Wire any of those wrong — leave it publicly accessible, skip encryption, forget the final snapshot — and you have either a breach or a destroyed warehouse.

This module wraps aws_redshift_cluster together with its subnet group, parameter group and logging into one var-driven unit. Every team gets the same secure baseline (encrypted, private, SSL-enforced, audited, snapshotted) while still exposing the knobs that legitimately differ per environment: node type, node count, retention, maintenance window.

When to use it

Reach for Redshift Serverless (aws_redshiftserverless_namespace / _workgroup) instead when workloads are spiky or intermittent and you would rather pay per RPU-hour than run nodes 24/7. This module targets the provisioned cluster, which is the right call for steady, predictable, high-throughput analytics where reserved-node pricing wins on cost.

Module structure

terraform-module-aws-redshift/
├── versions.tf      # provider + required Terraform version pins
├── main.tf          # subnet group, parameter group, cluster, IAM attachment
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, endpoint, port, arn, dns name, kms key

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Final snapshot only makes sense when we are NOT skipping it.
  final_snapshot_identifier = var.skip_final_snapshot ? null : "${var.cluster_identifier}-final-${formatdate("YYYYMMDDhhmmss", timestamp())}"

  tags = merge(
    {
      Name      = var.cluster_identifier
      ManagedBy = "terraform"
      Module    = "terraform-module-aws-redshift"
    },
    var.tags
  )
}

# Which private subnets the cluster's nodes are placed in.
resource "aws_redshift_subnet_group" "this" {
  name        = "${var.cluster_identifier}-subnets"
  description = "Subnet group for Redshift cluster ${var.cluster_identifier}"
  subnet_ids  = var.subnet_ids

  tags = local.tags
}

# Cluster-level configuration: SSL, user activity logging, and WLM.
resource "aws_redshift_parameter_group" "this" {
  name        = "${var.cluster_identifier}-params"
  family      = var.parameter_group_family
  description = "Parameter group for Redshift cluster ${var.cluster_identifier}"

  parameter {
    name  = "require_ssl"
    value = tostring(var.require_ssl)
  }

  parameter {
    name  = "enable_user_activity_logging"
    value = tostring(var.enable_user_activity_logging)
  }

  parameter {
    name  = "max_concurrency_scaling_clusters"
    value = tostring(var.max_concurrency_scaling_clusters)
  }

  parameter {
    name  = "wlm_json_configuration"
    value = var.wlm_json_configuration
  }

  tags = local.tags
}

resource "aws_redshift_cluster" "this" {
  cluster_identifier = var.cluster_identifier
  database_name      = var.database_name
  master_username    = var.master_username
  # Let Redshift generate and manage the admin password in Secrets Manager
  # so no plaintext secret ever lands in state or HCL.
  manage_master_password = true

  node_type       = var.node_type
  cluster_type    = var.number_of_nodes > 1 ? "multi-node" : "single-node"
  number_of_nodes = var.number_of_nodes

  cluster_subnet_group_name    = aws_redshift_subnet_group.this.name
  cluster_parameter_group_name = aws_redshift_parameter_group.this.name
  vpc_security_group_ids       = var.vpc_security_group_ids
  availability_zone_relocation_enabled = var.availability_zone_relocation_enabled

  # Hard security defaults.
  publicly_accessible = var.publicly_accessible
  encrypted           = true
  kms_key_id          = var.kms_key_arn
  enhanced_vpc_routing = var.enhanced_vpc_routing
  port                 = var.port

  # IAM roles let the cluster COPY/UNLOAD against S3 and call Glue.
  iam_roles            = var.iam_role_arns
  default_iam_role_arn = length(var.iam_role_arns) > 0 ? var.iam_role_arns[0] : null

  # Backups & maintenance.
  automated_snapshot_retention_period = var.automated_snapshot_retention_period
  preferred_maintenance_window        = var.preferred_maintenance_window
  allow_version_upgrade               = var.allow_version_upgrade

  # Lifecycle: take a final snapshot unless explicitly told not to.
  skip_final_snapshot       = var.skip_final_snapshot
  final_snapshot_identifier = local.final_snapshot_identifier

  # Stream audit logs to CloudWatch Logs when enabled.
  dynamic "logging" {
    for_each = var.enable_logging ? [1] : []
    content {
      log_destination_type = "cloudwatch"
      log_exports          = var.log_exports
    }
  }

  tags = local.tags

  lifecycle {
    # The auto-generated final snapshot id changes every plan; ignore it.
    ignore_changes = [final_snapshot_identifier]
  }
}

variables.tf

variable "cluster_identifier" {
  description = "Unique identifier for the Redshift cluster (lowercase, used as a name prefix for the subnet/parameter groups)."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{0,62}$", var.cluster_identifier))
    error_message = "cluster_identifier must start with a lowercase letter and contain only lowercase letters, numbers and hyphens (max 63 chars)."
  }
}

variable "database_name" {
  description = "Name of the first database created in the cluster."
  type        = string
  default     = "analytics"
}

variable "master_username" {
  description = "Admin username for the cluster. The password is generated and stored in Secrets Manager via manage_master_password."
  type        = string
  default     = "redshift_admin"

  validation {
    condition     = !contains(["admin", "root", "rdsadmin"], lower(var.master_username))
    error_message = "master_username must not be a reserved name (admin, root, rdsadmin)."
  }
}

variable "node_type" {
  description = "Redshift node type, e.g. ra3.xlplus, ra3.4xlarge, dc2.large."
  type        = string
  default     = "ra3.xlplus"

  validation {
    condition     = can(regex("^(ra3|dc2)\\.", var.node_type))
    error_message = "node_type must be an ra3.* or dc2.* node type."
  }
}

variable "number_of_nodes" {
  description = "Number of compute nodes. 1 = single-node, >1 = multi-node."
  type        = number
  default     = 2

  validation {
    condition     = var.number_of_nodes >= 1 && var.number_of_nodes <= 128
    error_message = "number_of_nodes must be between 1 and 128."
  }
}

variable "subnet_ids" {
  description = "List of private subnet IDs (in at least two AZs) for the cluster subnet group."
  type        = list(string)

  validation {
    condition     = length(var.subnet_ids) >= 2
    error_message = "Provide at least two subnet IDs across different AZs for resilience."
  }
}

variable "vpc_security_group_ids" {
  description = "Security group IDs controlling inbound access to the cluster (typically allowing port from BI/ETL subnets only)."
  type        = list(string)
}

variable "kms_key_arn" {
  description = "ARN of the KMS key used to encrypt the cluster at rest. Encryption is always enabled."
  type        = string

  validation {
    condition     = can(regex("^arn:aws[a-zA-Z-]*:kms:", var.kms_key_arn))
    error_message = "kms_key_arn must be a valid KMS key ARN."
  }
}

variable "iam_role_arns" {
  description = "IAM role ARNs to attach (for COPY/UNLOAD to S3, Glue Data Catalog access, etc.). The first is set as the default IAM role."
  type        = list(string)
  default     = []
}

variable "port" {
  description = "TCP port the cluster listens on."
  type        = number
  default     = 5439
}

variable "publicly_accessible" {
  description = "Whether the cluster is reachable from the public internet. Keep false in production."
  type        = bool
  default     = false
}

variable "enhanced_vpc_routing" {
  description = "Force COPY/UNLOAD traffic through your VPC (via VPC endpoints/NAT) instead of the public internet."
  type        = bool
  default     = true
}

variable "availability_zone_relocation_enabled" {
  description = "Allow Redshift to relocate the cluster to another AZ on failure (RA3 only)."
  type        = bool
  default     = false
}

variable "parameter_group_family" {
  description = "Parameter group family matching the cluster engine version."
  type        = string
  default     = "redshift-1.0"
}

variable "require_ssl" {
  description = "Require SSL/TLS for all client connections."
  type        = bool
  default     = true
}

variable "enable_user_activity_logging" {
  description = "Log all executed SQL statements (user activity log)."
  type        = bool
  default     = true
}

variable "max_concurrency_scaling_clusters" {
  description = "Max number of concurrency-scaling clusters to burst to for spiky query load."
  type        = number
  default     = 1
}

variable "wlm_json_configuration" {
  description = "Workload Management JSON. Default uses auto WLM with concurrency scaling enabled."
  type        = string
  default     = "[{\"auto_wlm\":true,\"queue_type\":\"auto\",\"concurrency_scaling\":\"auto\"}]"
}

variable "automated_snapshot_retention_period" {
  description = "Days to retain automated snapshots (0 disables automated backups)."
  type        = number
  default     = 7

  validation {
    condition     = var.automated_snapshot_retention_period >= 0 && var.automated_snapshot_retention_period <= 35
    error_message = "automated_snapshot_retention_period must be between 0 and 35 days."
  }
}

variable "preferred_maintenance_window" {
  description = "Weekly maintenance window in UTC, format ddd:hh24:mi-ddd:hh24:mi."
  type        = string
  default     = "sun:05:00-sun:06:00"
}

variable "allow_version_upgrade" {
  description = "Allow automatic engine version upgrades during the maintenance window."
  type        = bool
  default     = true
}

variable "enable_logging" {
  description = "Enable audit logging of connections/user activity to CloudWatch Logs."
  type        = bool
  default     = true
}

variable "log_exports" {
  description = "Log types to export to CloudWatch when logging is enabled."
  type        = list(string)
  default     = ["connectionlog", "userlog", "useractivitylog"]
}

variable "skip_final_snapshot" {
  description = "Skip the final snapshot on destroy. Keep false for any cluster holding real data."
  type        = bool
  default     = false
}

variable "tags" {
  description = "Additional tags merged onto every resource."
  type        = map(string)
  default     = {}
}

outputs.tf

output "cluster_id" {
  description = "The Redshift cluster identifier."
  value       = aws_redshift_cluster.this.id
}

output "cluster_arn" {
  description = "ARN of the Redshift cluster."
  value       = aws_redshift_cluster.this.arn
}

output "endpoint" {
  description = "Connection endpoint (host:port) for the cluster."
  value       = aws_redshift_cluster.this.endpoint
}

output "dns_name" {
  description = "DNS name of the cluster leader node."
  value       = aws_redshift_cluster.this.dns_name
}

output "port" {
  description = "Port the cluster accepts connections on."
  value       = aws_redshift_cluster.this.port
}

output "database_name" {
  description = "Name of the default database."
  value       = aws_redshift_cluster.this.database_name
}

output "master_password_secret_arn" {
  description = "ARN of the Secrets Manager secret holding the admin password (from manage_master_password)."
  value       = aws_redshift_cluster.this.master_password_secret_arn
}

output "subnet_group_name" {
  description = "Name of the created cluster subnet group."
  value       = aws_redshift_subnet_group.this.name
}

output "parameter_group_name" {
  description = "Name of the created cluster parameter group."
  value       = aws_redshift_parameter_group.this.name
}

How to use it

module "redshift" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-redshift?ref=v1.0.0"

  cluster_identifier = "kv-analytics-prod"
  database_name      = "warehouse"
  master_username    = "kv_admin"

  node_type       = "ra3.xlplus"
  number_of_nodes = 3

  subnet_ids             = module.vpc.private_subnet_ids
  vpc_security_group_ids = [aws_security_group.redshift.id]
  kms_key_arn            = aws_kms_key.redshift.arn

  # Role that lets COPY/UNLOAD read/write the data-lake bucket and use Glue.
  iam_role_arns = [aws_iam_role.redshift_s3.arn]

  enhanced_vpc_routing                = true
  require_ssl                         = true
  automated_snapshot_retention_period = 14
  preferred_maintenance_window        = "sun:07:00-sun:08:00"

  tags = {
    Environment = "prod"
    CostCenter  = "data-platform"
    Owner       = "analytics-eng"
  }
}

# Downstream: store the connection endpoint in SSM so the BI layer and
# scheduled ETL jobs can discover the warehouse without hardcoding it.
resource "aws_ssm_parameter" "redshift_endpoint" {
  name  = "/kv/prod/redshift/endpoint"
  type  = "String"
  value = module.redshift.endpoint
}

# Grant the dbt CI runner read access to the auto-generated admin secret.
data "aws_iam_policy_document" "dbt_secret_read" {
  statement {
    actions   = ["secretsmanager:GetSecretValue"]
    resources = [module.redshift.master_password_secret_arn]
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/redshift/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-redshift?ref=v1.0.0"
}

inputs = {
  cluster_identifier = "..."
  subnet_ids = ["...", "..."]
  vpc_security_group_ids = ["...", "..."]
  kms_key_arn = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/redshift && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
cluster_identifier string yes Unique cluster identifier; also prefixes the subnet/parameter group names.
database_name string "analytics" no Name of the first database created in the cluster.
master_username string "redshift_admin" no Admin username; password is managed in Secrets Manager.
node_type string "ra3.xlplus" no Node type (ra3.* or dc2.*).
number_of_nodes number 2 no Compute node count (1 = single-node, >1 = multi-node).
subnet_ids list(string) yes Private subnet IDs (≥2 AZs) for the subnet group.
vpc_security_group_ids list(string) yes Security groups controlling inbound access.
kms_key_arn string yes KMS key ARN for at-rest encryption (always enabled).
iam_role_arns list(string) [] no IAM roles to attach; first becomes the default IAM role.
port number 5439 no TCP port the cluster listens on.
publicly_accessible bool false no Whether the cluster is internet-reachable.
enhanced_vpc_routing bool true no Route COPY/UNLOAD traffic through the VPC.
availability_zone_relocation_enabled bool false no Allow AZ relocation on failure (RA3 only).
parameter_group_family string "redshift-1.0" no Parameter group family for the engine version.
require_ssl bool true no Require SSL/TLS for client connections.
enable_user_activity_logging bool true no Log all executed SQL statements.
max_concurrency_scaling_clusters number 1 no Max concurrency-scaling clusters to burst to.
wlm_json_configuration string auto WLM JSON no Workload Management configuration JSON.
automated_snapshot_retention_period number 7 no Days to retain automated snapshots (0–35).
preferred_maintenance_window string "sun:05:00-sun:06:00" no Weekly UTC maintenance window.
allow_version_upgrade bool true no Allow auto engine upgrades in the window.
enable_logging bool true no Export audit logs to CloudWatch Logs.
log_exports list(string) ["connectionlog","userlog","useractivitylog"] no Log types to export.
skip_final_snapshot bool false no Skip the final snapshot on destroy.
tags map(string) {} no Additional tags merged onto every resource.

Outputs

Name Description
cluster_id The Redshift cluster identifier.
cluster_arn ARN of the Redshift cluster.
endpoint Connection endpoint (host:port).
dns_name DNS name of the leader node.
port Port the cluster accepts connections on.
database_name Name of the default database.
master_password_secret_arn ARN of the Secrets Manager secret holding the admin password.
subnet_group_name Name of the created cluster subnet group.
parameter_group_name Name of the created cluster parameter group.

Enterprise scenario

A retail analytics team consolidates point-of-sale, e-commerce and loyalty data into a single ra3.4xlarge warehouse for the BI and finance teams. They deploy this module once per environment from a pipeline: dev runs two nodes with 1-day snapshot retention, while prod runs six nodes with 30-day retention, enhanced_vpc_routing forcing all COPY traffic from the S3 data lake through VPC endpoints, and an attached IAM role scoped to exactly the lake bucket and Glue catalog. Because manage_master_password, KMS encryption, require_ssl and CloudWatch audit logging are baked into the module defaults, every cluster passes the company’s PCI control review with no per-environment tweaking, and the endpoint published to SSM lets the dbt and Looker layers discover the warehouse without anyone hardcoding a hostname.

Best practices

TerraformAWSRedshiftModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading