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
- You are standing up a central analytics / BI warehouse and want the same encrypted, private, audited cluster in dev, staging and prod from one source of truth.
- You run multiple data domains or business units that each need their own cluster but must conform to a single security and tagging standard.
- You need at-rest KMS encryption, SSL enforcement and audit logging on by default for SOC 2 / ISO 27001 / PCI and want it impossible to forget.
- You want RA3 + Redshift Managed Storage so analysts can grow data without re-architecting, with snapshot retention codified.
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 config — live/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 config — live/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
- Never go public, always encrypt. Keep
publicly_accessible = false, place the cluster in private subnets, and let the module enforceencrypted = truewith a customer-managed KMS key so you control rotation and key policy — not AWS’s default key. - Let AWS hold the admin password.
manage_master_password = truekeeps the credential in Secrets Manager and out of state and HCL; grant downstream consumers read access viamaster_password_secret_arnrather than passing a plaintext password variable. - Right-size with RA3 and concurrency scaling, not idle nodes. Use RA3 + Redshift Managed Storage so data growth doesn’t force you onto bigger compute; cap burst cost with
max_concurrency_scaling_clustersand buy reserved nodes for the steady-state baseline once usage is predictable. - Protect the data on destroy. Keep
skip_final_snapshot = falsefor any cluster with real data and setautomated_snapshot_retention_periodto match your RPO (7 days dev, 30+ prod); a destroyed cluster with no final snapshot is unrecoverable. - Keep traffic and access tight. Turn on
enhanced_vpc_routingsoCOPY/UNLOADstays inside the VPC, restrict the security group to only the BI/ETL CIDRs on port 5439, and attach narrowly-scoped IAM roles (least privilege to specific S3 prefixes and Glue databases) instead of broad managed policies. - Name and tag for accountability. Drive every cluster from
cluster_identifierplus mandatoryEnvironment,CostCenterandOwnertags so cost allocation, snapshot audits and incident ownership are unambiguous across dozens of warehouses.