RDS PostgreSQL with Terraform CI/CD

Deploy a PostgreSQL database on AWS using Terraform and a GitLab CI/CD pipeline.

Introduction

30 Second Summary

Every app you use stores data somewhere. When a team needs to set up a new database, clicking through a cloud console works once, but doing it consistently, securely, and repeatedly across environments requires automation.

In this project, you will write Terraform code that provisions a PostgreSQL database on AWS and build a GitLab CI/CD pipeline that validates, plans, and deploys your infrastructure automatically.

What You'll Build

You will create a fully automated database deployment pipeline that provisions networking, security, and a production-style RDS PostgreSQL instance entirely through code.

By the end of this project, you'll have:

  • A Terraform project with separate files for networking, database configuration, variables, and outputs.
  • A GitLab CI/CD pipeline with validate, plan, and manual-approval apply stages using remote state management.
  • A running RDS PostgreSQL instance with a custom parameter group, encrypted storage, and AWS-managed credentials.
  • Secret Mission: Add a read replica to your database through the pipeline and verify replication is working.

Are there any prerequisites?

You need an AWS account with admin access and basic command-line familiarity on Windows. You should also have Terraform, VS Code, and Git installed on your machine.

Install and Configure Tools

Deploying a database through code requires a chain of tools working together. Terraform writes the infrastructure definition, the AWS CLI authenticates with your cloud account, and GitLab orchestrates the pipeline that ties it all together.

In this step, you will verify your Terraform installation, configure AWS credentials, create a GitLab project, and set up the local file structure that will hold your infrastructure code.

In this step, get ready to:
  • Verify Terraform and configure the AWS CLI with your credentials.
  • Create a GitLab account and project with CI/CD variables for AWS authentication.
  • Initialize the local project file structure.
Verify Terraform and configure the AWS CLI

Before writing any infrastructure code, you need to confirm that Terraform can run on your machine and that the AWS CLI can authenticate against your AWS account.

  • Open VS Code by pressing the Windows key, typing VS Code, and pressing Enter.
  • Open a terminal inside VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Confirm Terraform is installed by running this command:
terraform --version

You should see output showing v1.15.x (for example, Terraform v1.15.7).

Don't see a version number?

If you see a "command not found" or "not recognized" error, Terraform is not installed or not in your system PATH. Revisit the Terraform installation guide and ensure the terraform binary is added to your PATH environment variable.

After updating your PATH, close and reopen the VS Code terminal for the change to take effect.

help me troubleshoot why terraform --version is not working on Windows

Next, check whether the AWS CLI is already on your machine by running:

  • Check if the AWS CLI is installed by running this command:
aws --version

✔️ I see a version number

The AWS CLI is installed and ready to configure. Move on to the next instruction below.

ⓧ Command not found

The AWS CLI is not installed yet. Install it using the official MSI installer.

  • Download the official AWS CLI installer for Windows from this link:
  • https://awscli.amazonaws.com/AWSCLIV2.msi
  • Run the downloaded AWSCLIV2.msi file and follow the on-screen prompts to complete installation.
  • Close and reopen the VS Code terminal after installation finishes.
  • Run aws --version again to confirm it works.

Now configure the AWS CLI with your credentials. The aws configure command will prompt you for four values interactively.

  • Run the configuration command:
aws configure

What will this command ask?

The command prompts you for four values, one at a time. Enter each and press Enter:

  • AWS Access Key ID - paste your IAM user's access key.
  • AWS Secret Access Key - paste the corresponding secret key.
  • Default region name - type us-east-1.
  • Default output format - type json.

These credentials let Terraform and the AWS CLI create resources in your AWS account. You will also store these same values in GitLab later so the pipeline can authenticate.

  • Verify your credentials are working by running this command:
aws sts get-caller-identity

You should see a JSON response containing your Account, UserId, and Arn fields.

Seeing an error instead of JSON?

If you see Unable to locate credentials, the aws configure step did not save correctly. Run aws configure again and double-check you pasted the full Access Key ID and Secret Access Key without extra spaces.

If you see InvalidClientTokenId, your access key may have been deactivated or typed incorrectly. Check the IAM console to confirm the key is active.

help me fix my AWS CLI credentials error

Create a GitLab account and project

GitLab will host your Terraform code and run the CI/CD pipeline that deploys your database. The free tier is sufficient for everything in this project.

  • Open your browser and go to gitlab.com.
  • Click Register now and complete the sign-up process (you can sign up with your email or GitHub account).
  • After signing in, click New project on the dashboard.
  • Select Create blank project.
  • Enter rds-terraform-pipeline as the project name.
  • Leave the visibility as Private and check Initialize repository with a README.
  • Click Create project.

Now add the AWS credentials as CI/CD variables so the pipeline can authenticate with AWS without exposing secrets in code.

  • In your new GitLab project, click Settings in the left sidebar.
  • Click CI/CD.
  • Expand the Variables section.
  • Click Add variable and add the first variable:
  • Set Key to AWS_ACCESS_KEY_ID.
  • Paste your Access Key ID as the Value.
  • Check both Protect variable and Mask variable.
  • Click Add variable to save.
  • Repeat the process for AWS_SECRET_ACCESS_KEY (paste your secret key as the value, protect and mask it).
  • Repeat once more for AWS_DEFAULT_REGION with the value us-east-1 (protect and mask it).

Why store credentials as CI/CD variables?

CI/CD variables inject values into pipeline jobs at runtime. Masking prevents the values from appearing in job logs, and protecting them means they are only available on protected branches (like main).

This is the industry-standard approach for secrets in CI/CD. You never hardcode credentials in your Terraform files or commit them to version control.

Can't find the Variables section?

Make sure you are in Settings > CI/CD, not Settings > General. The Variables section is collapsed by default. Click the Expand button next to it.

help me find the CI/CD variables section in my GitLab project settings

Initialize the local project structure

Separating Terraform into multiple files by concern (networking, database, variables) is an industry best practice. It makes code reviewable, maintainable, and allows teams to work on different components without merge conflicts.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your GitLab repository by running this command:
git clone [[YOUR_REPO_URL="https://gitlab.com/your-username/rds-terraform-pipeline.git"]]

Where do I find my repository URL?

On your GitLab project page, click the blue Code button near the top-right. Copy the URL under Clone with HTTPS.

  • Move into the cloned project folder by running:
cd rds-terraform-pipeline
  • Create the Terraform and pipeline files by running these commands:
New-Item providers.tf, backend.tf, variables.tf, networking.tf, database.tf, outputs.tf, .gitlab-ci.yml

What are these files for?

  • providers.tf - declares which cloud provider (AWS) and Terraform version to use.
  • backend.tf - configures where Terraform stores its state file (GitLab-managed remote state).
  • variables.tf - defines reusable input variables (region, instance class, database name).
  • networking.tf - holds VPC, subnet, and security group resources.
  • database.tf - holds the RDS instance and parameter group resources.
  • outputs.tf - exports values (like the database endpoint) for other tools to consume.
  • .gitlab-ci.yml - defines the CI/CD pipeline stages that validate, plan, and apply your Terraform.
  • Confirm the files were created by running:
ls

You should see providers.tf, backend.tf, variables.tf, networking.tf, database.tf, outputs.tf, and .gitlab-ci.yml listed in the output.

Don't see all seven files?

If New-Item was not recognized, you may be using Command Prompt instead of PowerShell. Switch to PowerShell by typing powershell and pressing Enter, then run the file creation command again.

If .gitlab-ci.yml does not appear in ls output, try ls -Force to show hidden files (files starting with a dot are hidden by default in some shells).

help me create the Terraform project files on Windows

Your tools are installed, credentials are configured, and the project skeleton is ready. Next up, you will write the Terraform foundation that tells your code which cloud provider to use and where to store its state.

Write the Terraform Foundation

Your project structure is in place with empty files ready to go. Now it is time to fill them with the Terraform configuration that tells your infrastructure exactly what to build and where to store its state.

Every Infrastructure as Code project starts with a foundation: which cloud provider to use, where to store the record of what has been created, and what values can be changed between environments. In this step, you will write that foundation across four files.

In this step, get ready to:
  • Configure the AWS provider and remote state backend.
  • Define input variables that make your code reusable across environments.
  • Define outputs that export key resource information for downstream use.
Configure the Terraform provider and remote backend

Terraform needs to know two things before it can do anything: which cloud provider to talk to, and where to store its state file. The state file is a record of every resource Terraform has created, so two pipeline runs never conflict with each other.

  • In VS Code, open providers.tf and paste the following code:
terraform {
  required_version = ">= 1.15.0"

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

provider "aws" {
  region = var.aws_region
}

What does this code do?

The terraform block pins the minimum Terraform version to 1.15.0 and locks the AWS provider to version ~> 6.28 (any 6.x release from 6.28 onward). This prevents surprise breakages when a new major version ships.

The provider "aws" block reads the region from a variable instead of hardcoding it. This means you can deploy to any region by changing a single value.

Next, you will configure where Terraform stores its state. GitLab provides an HTTP backend that handles state locking, encryption, and versioning automatically.

  • Open backend.tf and paste the following code:
terraform {
  backend "http" {
  }
}

Why is the backend block empty?

The backend "http" {} block tells Terraform to use an HTTP backend for state storage, but leaves the connection details blank on purpose. GitLab injects the actual address, lock URL, and credentials at runtime through environment variables in the CI/CD pipeline.

This means your code stays generic and portable. The same file works for every GitLab project without modification.

  • Save providers.tf and backend.tf.

You will not see runtime output from these files yet. The pipeline uses them when it runs terraform init in Step 5.

Seeing a syntax highlight error?

Make sure you have the HashiCorp Terraform VS Code extension installed for .tf syntax highlighting. Without it, VS Code may flag valid HCL as unrecognized.

Also check that each opening brace { has a matching closing brace }. help me fix a syntax error in my Terraform providers.tf or backend.tf file

Define input variables

Variables make your Terraform code reusable. Instead of hardcoding values like the database name or instance size, you define them once and reference them everywhere.

  • Open variables.tf and paste the following code:
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Project name used for resource naming"
  type        = string
  default     = "nextwork-rds"
}

variable "db_name" {
  description = "Name of the database to create"
  type        = string
  default     = "appdb"
}

variable "db_username" {
  description = "Master username for the database"
  type        = string
  default     = "dbadmin"
}

variable "db_instance_class" {
  description = "RDS instance class"
  type        = string
  default     = "db.t4g.micro"
}

variable "db_allocated_storage" {
  description = "Allocated storage in GB"
  type        = number
  default     = 20
}

variable "db_engine_version" {
  description = "PostgreSQL engine version"
  type        = string
  default     = "17.8"
}

What does this code do?

  • aws_region sets where your resources are deployed. The default us-east-1 matches the region you configured in your AWS CLI.
  • project_name prefixes all resource names so you can identify what belongs to this project in the AWS Console.
  • db_instance_class specifies db.t4g.micro, which is a Graviton-based instance eligible for the RDS free tier (750 hours per month).
  • db_engine_version pins PostgreSQL to version 17.8, the current stable minor release on RDS.

Why use variables instead of hardcoded values?

Variables let you deploy to dev, staging, or production by changing values in one place. You never modify the core logic files. This is the same pattern every infrastructure team uses to manage multiple environments from a single codebase.

  • Save variables.tf.

You will not see a change yet. These variables get referenced when you define the actual resources in the next steps.

Seeing a type mismatch warning?

Check that db_allocated_storage uses type = number (not string) and its default is 20 without quotes. Numbers in HCL do not get wrapped in quotation marks.

help me fix a type error in my Terraform variables.tf file

Define outputs for downstream use

Outputs export information about the resources Terraform creates. Other projects or your application deployment pipeline can read these values to configure database connections automatically.

  • Open outputs.tf and paste the following code:
output "db_endpoint" {
  description = "RDS instance endpoint"
  value       = aws_db_instance.primary.endpoint
}

output "db_port" {
  description = "RDS instance port"
  value       = aws_db_instance.primary.port
}

output "db_name" {
  description = "Database name"
  value       = aws_db_instance.primary.db_name
}

output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.main.id
}

What does this code do?

  • db_endpoint exports the connection hostname and port that applications use to connect to your database.
  • db_port exports the PostgreSQL port (5432 by default).
  • db_name exports the name of the database created inside the RDS instance.
  • vpc_id exports the VPC identifier so other Terraform projects can reference the same network.

These outputs reference resources (aws_db_instance.primary and aws_vpc.main) that you will create in the next two steps. Terraform resolves them at apply time, not at write time.

  • Save outputs.tf.
  • Confirm your formatting is correct by running this command in the VS Code terminal:
terraform fmt -check

You should see no output, which means all your .tf files are correctly formatted.

Seeing a filename printed after running fmt?

If terraform fmt -check prints a filename, that file has a formatting issue. Run terraform fmt (without -check) to auto-fix the formatting, then open the file to see what changed.

Common causes: inconsistent indentation (HCL uses 2 spaces) or missing alignment on = signs.

help me fix Terraform formatting issues in my .tf files

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each file you wrote in this step.

terraform {
  required_version = ">= 1.15.0"

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

provider "aws" {
  region = var.aws_region
}
terraform {
  backend "http" {
  }
}
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Project name used for resource naming"
  type        = string
  default     = "nextwork-rds"
}

variable "db_name" {
  description = "Name of the database to create"
  type        = string
  default     = "appdb"
}

variable "db_username" {
  description = "Master username for the database"
  type        = string
  default     = "dbadmin"
}

variable "db_instance_class" {
  description = "RDS instance class"
  type        = string
  default     = "db.t4g.micro"
}

variable "db_allocated_storage" {
  description = "Allocated storage in GB"
  type        = number
  default     = 20
}

variable "db_engine_version" {
  description = "PostgreSQL engine version"
  type        = string
  default     = "17.8"
}
output "db_endpoint" {
  description = "RDS instance endpoint"
  value       = aws_db_instance.primary.endpoint
}

output "db_port" {
  description = "RDS instance port"
  value       = aws_db_instance.primary.port
}

output "db_name" {
  description = "Database name"
  value       = aws_db_instance.primary.db_name
}

output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.main.id
}

Your Terraform foundation is locked in. Next up, you will build the network layer that gives your database a secure, isolated home inside AWS.

Build the Network Layer

Your Terraform foundation is set. You have providers configured, a remote backend ready, and variables defined for every configurable value.

Now it's time to build the network that your database will live in. Every RDS instance needs to sit inside a VPC with subnets spread across multiple Availability Zones. In this step, you'll create that entire network layer as code.

In this step, get ready to:
  • Create a VPC with subnets in two Availability Zones.
  • Define a DB subnet group and security group to control database access.
Create the VPC and subnets

A VPC (Virtual Private Cloud) is your isolated network in AWS. RDS instances must be placed inside a VPC, and AWS requires at least two subnets in different Availability Zones for high availability.

  • In VS Code, open networking.tf (it should be empty).
  • Add the VPC and subnet resources by pasting this code:
# Create an isolated network for the database
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.project_name}-vpc"
  }
}

# First database subnet in Availability Zone A
resource "aws_subnet" "db_subnet_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "${var.aws_region}a"

  tags = {
    Name = "${var.project_name}-db-subnet-a"
  }
}

# Second database subnet in Availability Zone B
resource "aws_subnet" "db_subnet_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.2.0/24"
  availability_zone = "${var.aws_region}b"

  tags = {
    Name = "${var.project_name}-db-subnet-b"
  }
}

What does this code do?

  • aws_vpc "main" creates your isolated network with a 10.0.0.0/16 address range (65,536 possible IPs).
  • enable_dns_hostnames = true is required for RDS to generate readable endpoint URLs like nextwork-rds-primary.abc123.us-east-1.rds.amazonaws.com.
  • The two aws_subnet resources place database subnets in us-east-1a and us-east-1b. RDS requires at least two subnets in different Availability Zones, even for single-AZ deployments.
  • Save networking.tf.

You won't see a visible change yet. The next chunk adds the subnet group and security group that reference these resources.

Seeing red squiggles in VS Code?

  • Make sure every opening brace { has a matching closing brace }. HCL is sensitive to brace placement.
  • Check that var.project_name and var.aws_region are spelled exactly as defined in variables.tf.

Still stuck? Help me fix a syntax error in my networking.tf VPC and subnet resources.

Create the DB subnet group and security group

The DB subnet group tells RDS which subnets it can use. The security group acts as a firewall, controlling which traffic can reach your database.

  • Add the following code below the subnets you just wrote in networking.tf:
# Bundle both subnets into a group for RDS
resource "aws_db_subnet_group" "main" {
  name       = "${var.project_name}-db-subnet-group"
  subnet_ids = [aws_subnet.db_subnet_a.id, aws_subnet.db_subnet_b.id]

  tags = {
    Name = "${var.project_name}-db-subnet-group"
  }
}

# Firewall rules for the database
resource "aws_security_group" "db" {
  name        = "${var.project_name}-db-sg"
  description = "Security group for RDS PostgreSQL"
  vpc_id      = aws_vpc.main.id

  # Only allow PostgreSQL traffic from within the VPC
  ingress {
    description = "PostgreSQL from VPC"
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/16"]
  }

  # Allow all outbound traffic
  egress {
    description = "Allow all outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-db-sg"
  }
}

What does this code do?

  • aws_db_subnet_group "main" bundles both subnets into a group that RDS can reference. This is a required RDS construct.
  • The aws_security_group ingress rule opens port 5432 (PostgreSQL) only to traffic from within the VPC (10.0.0.0/16). Nothing outside the VPC can reach your database.
  • The egress rule allows all outbound traffic so the database can communicate with AWS services like Secrets Manager.

In production, you would restrict the ingress CIDR to your application's security group rather than the entire VPC. For this learning project, VPC-internal access is a safe starting point.

  • Save networking.tf.
  • Confirm your file is valid HCL by running this command in your VS Code terminal:
terraform fmt networking.tf

If the file needed reformatting, you'll see the filename printed. If nothing prints, the file is already properly formatted.

Seeing an error from terraform fmt?

  • Check that cidr_blocks uses square brackets (it's a list), not a bare string.
  • Make sure from_port and to_port are numbers (5432), not strings in quotes.
  • Verify every ingress and egress block has matching braces.

Still stuck? Help me fix a terraform fmt error in my networking.tf security group.

Why restrict access to VPC-internal only?

The principle of least privilege means you only open the exact port your database needs (5432 for PostgreSQL) and only from trusted sources. A database exposed to the public internet is one of the most common cloud security mistakes.

By limiting ingress to 10.0.0.0/16, only resources inside your VPC can connect. External traffic is completely blocked.

✔️ Awesome, I've got everything!

Great. Double check you've saved networking.tf and that terraform fmt ran without errors.

ⓧ I'd like to double check the full code

Here is the complete networking.tf file. Compare it against yours:

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.project_name}-vpc"
  }
}

resource "aws_subnet" "db_subnet_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "${var.aws_region}a"

  tags = {
    Name = "${var.project_name}-db-subnet-a"
  }
}

resource "aws_subnet" "db_subnet_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.2.0/24"
  availability_zone = "${var.aws_region}b"

  tags = {
    Name = "${var.project_name}-db-subnet-b"
  }
}

resource "aws_db_subnet_group" "main" {
  name       = "${var.project_name}-db-subnet-group"
  subnet_ids = [aws_subnet.db_subnet_a.id, aws_subnet.db_subnet_b.id]

  tags = {
    Name = "${var.project_name}-db-subnet-group"
  }
}

resource "aws_security_group" "db" {
  name        = "${var.project_name}-db-sg"
  description = "Security group for RDS PostgreSQL"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "PostgreSQL from VPC"
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/16"]
  }

  egress {
    description = "Allow all outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-db-sg"
  }
}

Your network layer is complete. Next up, you'll define the RDS PostgreSQL instance and its custom parameter group to sit inside this network.

Deploy the Database

Your network layer is complete. A VPC, two private subnets, and a security group are ready to host your database.

Now you'll define the database itself in Terraform. This step creates a custom parameter group for PostgreSQL tuning and an RDS instance that uses all the networking resources from the previous step.

In this step, get ready to:
  • Create a custom PostgreSQL parameter group with connection logging.
  • Define an RDS PostgreSQL instance with encryption and managed secrets.
Create a custom parameter group

Parameter groups control how PostgreSQL behaves at the engine level. You need a custom one because AWS's default parameter group cannot be modified.

  • Open database.tf in VS Code and add the parameter group resource by pasting the following code:
resource "aws_db_parameter_group" "postgres" {
  name   = "${var.project_name}-postgres17"
  family = "postgres17"

  parameter {
    name  = "log_connections"
    value = "1"
  }

  parameter {
    name  = "log_disconnections"
    value = "1"
  }

  tags = {
    Name = "${var.project_name}-pg"
  }
}

What does this code do?

  • family = "postgres17" ties this parameter group to PostgreSQL 17 engine settings. The family must match your database engine version.
  • log_connections = "1" records every new connection in the database logs. This is useful for security auditing.
  • log_disconnections = "1" records when clients disconnect, completing the audit trail of who connected and when.

In production, you would also tune performance parameters like shared_buffers (controls how much memory PostgreSQL uses for caching). For the free-tier db.t4g.micro instance, the defaults are already reasonable.

You won't see a change yet. The next chunk creates the RDS instance that references this parameter group.

Why use a custom parameter group?

You cannot modify the default parameter group that AWS provides. If you start with the default and later need to change a setting, you would have to create a custom group and reboot your database to switch.

Starting with a custom parameter group means you can adjust settings anytime without that migration headache.

Create the RDS PostgreSQL instance

The RDS instance is the actual database server. It pulls together your networking layer, security group, and parameter group into one managed resource.

  • Add the RDS instance resource below the parameter group in database.tf by pasting the following code:
resource "aws_db_instance" "primary" {
  identifier     = "${var.project_name}-primary"
  engine         = "postgres"
  engine_version = var.db_engine_version
  instance_class = var.db_instance_class

  allocated_storage = var.db_allocated_storage
  storage_type      = "gp2"
  storage_encrypted = true

  db_name  = var.db_name
  username = var.db_username

  manage_master_user_password = true

  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.db.id]
  parameter_group_name   = aws_db_parameter_group.postgres.name

  multi_az            = false
  publicly_accessible = false
  skip_final_snapshot = true

  backup_retention_period = 7
  backup_window           = "03:00-04:00"
  maintenance_window      = "Mon:04:00-Mon:05:00"

  tags = {
    Name        = "${var.project_name}-primary"
    Environment = "dev"
  }
}

What does this code do?

  • manage_master_user_password = true tells AWS to generate and store the database password in Secrets Manager automatically. No password appears in your Terraform code or state file.
  • storage_encrypted = true enables encryption at rest. This is a compliance requirement in most organizations.
  • skip_final_snapshot = true is set because this is a learning project. In production, you would always take a final snapshot before deleting a database.
  • multi_az = false keeps costs within free tier. In production, you would set this to true for automatic failover to a standby in another availability zone.
  • The three reference lines (db_subnet_group_name, vpc_security_group_ids, parameter_group_name) connect this instance to the networking and configuration resources you created earlier.

The backup_window and maintenance_window schedule automated backups and patches during low-traffic hours (3-5 AM UTC).

  • Save database.tf.
  • Confirm your HCL syntax is valid by running this command in your VS Code terminal:
terraform fmt database.tf

If the command returns silently with no output, your file is properly formatted. If it prints the filename, Terraform auto-corrected minor formatting issues for you.

Seeing an error from terraform fmt?

  • Check for missing closing braces (}). Every opening { needs a matching close.
  • Make sure all string values are wrapped in double quotes. Single quotes are not valid in HCL.
  • Verify that your resource references (like aws_db_subnet_group.main.name) match the resource names defined in networking.tf.

Help me debug a terraform fmt error in my database.tf file

✔️ Awesome, I've got everything!

Great. Make sure you've saved database.tf before moving on.

ⓧ I'd like to double check the full code

Here is the complete database.tf file:

resource "aws_db_parameter_group" "postgres" {
  name   = "${var.project_name}-postgres17"
  family = "postgres17"

  parameter {
    name  = "log_connections"
    value = "1"
  }

  parameter {
    name  = "log_disconnections"
    value = "1"
  }

  tags = {
    Name = "${var.project_name}-pg"
  }
}

resource "aws_db_instance" "primary" {
  identifier     = "${var.project_name}-primary"
  engine         = "postgres"
  engine_version = var.db_engine_version
  instance_class = var.db_instance_class

  allocated_storage = var.db_allocated_storage
  storage_type      = "gp2"
  storage_encrypted = true

  db_name  = var.db_name
  username = var.db_username

  manage_master_user_password = true

  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.db.id]
  parameter_group_name   = aws_db_parameter_group.postgres.name

  multi_az            = false
  publicly_accessible = false
  skip_final_snapshot = true

  backup_retention_period = 7
  backup_window           = "03:00-04:00"
  maintenance_window      = "Mon:04:00-Mon:05:00"

  tags = {
    Name        = "${var.project_name}-primary"
    Environment = "dev"
  }
}

Your database is fully defined as code. Next up, you'll build the GitLab CI/CD pipeline that validates, plans, and deploys this infrastructure automatically.

Build and Trigger the CI/CD Pipeline

Your Terraform code is complete. Every resource your database needs is defined in code, from the VPC and subnets to the RDS instance itself.

Now you need a way to actually run that code against AWS without doing it manually from your laptop. This is where the GitLab CI/CD pipeline comes in. You will write a pipeline that validates your Terraform, generates an execution plan, and waits for your manual approval before provisioning real infrastructure.

In this step, get ready to:
  • Write the pipeline's global configuration (image, stages, variables, and the shared before_script).
  • Define the validate, plan, and apply jobs with proper rules and artifacts.
  • Push your code to GitLab, watch the pipeline run, and manually approve the apply to deploy your RDS database.
Write the pipeline configuration

Every GitLab CI/CD pipeline lives in a single file called .gitlab-ci.yml at the root of your repository. The global section tells GitLab which Docker image to run jobs in, what stages exist, and what setup to do before each job.

  • Open .gitlab-ci.yml in VS Code and paste the following global pipeline configuration:
# Docker image with Terraform pre-installed
image:
  name: hashicorp/terraform:1.15
  entrypoint: [""]

# Pipeline runs through these stages in order
stages:
  - validate
  - plan
  - apply

# Variables available to all jobs
variables:
  TF_ROOT: ${CI_PROJECT_DIR}
  TF_STATE_NAME: default
  TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}

# Runs before every job - configures remote state and initializes Terraform
before_script:
  - cd ${TF_ROOT}
  - export TF_HTTP_ADDRESS=${TF_ADDRESS}
  - export TF_HTTP_LOCK_ADDRESS=${TF_ADDRESS}/lock
  - export TF_HTTP_UNLOCK_ADDRESS=${TF_ADDRESS}/lock
  - export TF_HTTP_USERNAME=gitlab-ci-token
  - export TF_HTTP_PASSWORD=${CI_JOB_TOKEN}
  - export TF_HTTP_LOCK_METHOD=POST
  - export TF_HTTP_UNLOCK_METHOD=DELETE
  - terraform init

What does this configuration do?

The image block tells GitLab to run every job inside the official hashicorp/terraform:1.15 Docker container. This guarantees a consistent Terraform version regardless of what developers have locally. The empty entrypoint overrides the container's default entrypoint so GitLab's runner can execute shell commands.

The before_script runs before every job. It exports the HTTP backend environment variables that tell Terraform where to store state in GitLab. CI_JOB_TOKEN is automatically provided by GitLab to every CI job, so you do not need extra tokens. The lock method (POST/DELETE) prevents two pipeline runs from modifying state at the same time.

Why use GitLab-managed state instead of S3?

For a learning project, GitLab-managed state is zero-setup. You do not need to create an S3 bucket or a DynamoDB lock table. GitLab provides automatic state locking (only one apply at a time), encryption at rest, and versioning out of the box.

Production teams often use S3 plus DynamoDB for cross-tool access. That is a valid approach, but it adds significant setup that does not teach database engineering concepts.

  • Save .gitlab-ci.yml.
  • You will not see the pipeline run yet. The next section adds the jobs that give it something to do.
Define the validate, plan, and apply jobs

Each stage you declared needs at least one job. Jobs define the actual commands that run. You will create three jobs that implement the "plan then approve then apply" workflow used by every serious infrastructure team.

  • In .gitlab-ci.yml, add the following three job definitions below the before_script block:
validate:
  stage: validate
  script:
    - terraform validate
    - terraform fmt -check
  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH

plan:
  stage: plan
  script:
    - terraform plan -out=tfplan
  artifacts:
    paths:
      - ${TF_ROOT}/tfplan
    expire_in: 7 days
  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

apply:
  stage: apply
  script:
    - terraform apply -input=false tfplan
  dependencies:
    - plan
  environment:
    name: production
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual

What does each job do?

  • validate runs terraform validate and terraform fmt -check on every branch push and merge request. This catches syntax errors and formatting issues early.
  • plan runs terraform plan -out=tfplan and saves the plan file as an artifact for 7 days. It only runs on merge requests and the main branch. The saved artifact ensures what was reviewed is exactly what gets applied.
  • apply runs terraform apply -input=false tfplan using the plan artifact. It only runs on the main branch and requires manual approval (when: manual). This prevents accidental infrastructure changes.
  • Save .gitlab-ci.yml.

YAML indentation matters

YAML uses spaces (not tabs) for indentation. If you see parse errors later, check that every nested key is indented with exactly 2 spaces. The rules key sits at the same level as script, and each - if: line is indented one level deeper.

help me fix YAML indentation errors in my .gitlab-ci.yml

✔️ Awesome, I've got everything!

Great. Double check you have saved .gitlab-ci.yml with both the global config and the three jobs.

ⓧ I'd like to double check the full code

Here is the complete .gitlab-ci.yml file:

image:
  name: hashicorp/terraform:1.15
  entrypoint: [""]

stages:
  - validate
  - plan
  - apply

variables:
  TF_ROOT: ${CI_PROJECT_DIR}
  TF_STATE_NAME: default
  TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}

before_script:
  - cd ${TF_ROOT}
  - export TF_HTTP_ADDRESS=${TF_ADDRESS}
  - export TF_HTTP_LOCK_ADDRESS=${TF_ADDRESS}/lock
  - export TF_HTTP_UNLOCK_ADDRESS=${TF_ADDRESS}/lock
  - export TF_HTTP_USERNAME=gitlab-ci-token
  - export TF_HTTP_PASSWORD=${CI_JOB_TOKEN}
  - export TF_HTTP_LOCK_METHOD=POST
  - export TF_HTTP_UNLOCK_METHOD=DELETE
  - terraform init

validate:
  stage: validate
  script:
    - terraform validate
    - terraform fmt -check
  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH

plan:
  stage: plan
  script:
    - terraform plan -out=tfplan
  artifacts:
    paths:
      - ${TF_ROOT}/tfplan
    expire_in: 7 days
  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

apply:
  stage: apply
  script:
    - terraform apply -input=false tfplan
  dependencies:
    - plan
  environment:
    name: production
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
Push to GitLab and trigger the pipeline

Your pipeline file is ready. Pushing to the main branch will trigger it automatically. This is the real workflow: in a team, you would push to a feature branch, create a merge request, review the plan output together, merge to main, and then approve the apply.

  • Stage all your project files by running this command:
git add .
  • Commit the files by running:
git commit -m "Initial Terraform RDS deployment"
  • Push to the main branch by running:
git push origin main

What happens after the push?

GitLab detects the .gitlab-ci.yml file and immediately starts running your pipeline. The validate and plan jobs run automatically. The apply job waits for your manual approval before touching AWS.

  • Switch to your GitLab project in the browser.
  • In the left sidebar, select Build, then Pipelines.
  • You should see a pipeline with three stages. The validate and plan stages should pass automatically within a minute or two.
  • Click the pipeline to open the details page and view the job graph.
  • Click the plan job to review its log output. You should see Terraform listing all the resources it plans to create (VPC, subnets, security group, parameter group, and the RDS instance).

Review the plan before approving

The plan output shows exactly what Terraform will create, modify, or destroy. In a real team, this is where you and a colleague would review changes before anything touches production. Look for the summary line at the bottom showing the number of resources to add.

  • Return to the pipeline details page.
  • Click the play button on the apply job to manually trigger it.
  • Wait approximately 5-8 minutes for RDS to provision. The apply job log will show Terraform creating each resource one by one.
  • When the job finishes, look for the db_endpoint output in the job log. This is your RDS connection endpoint.

Pipeline failing at the validate or plan stage?

Check that your CI/CD variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION) are set correctly in your GitLab project under Settings > CI/CD > Variables. The variable names must match exactly (all caps with underscores).

If you see a formatting error from terraform fmt -check, run terraform fmt locally to auto-fix formatting, commit the changes, and push again.

help me debug why my GitLab CI/CD pipeline is failing on the Terraform validate or plan stage

Your database is live. You just deployed a production-style RDS PostgreSQL instance entirely through a CI/CD pipeline. Every change to this infrastructure from now on flows through code review and approval before touching AWS.

Secret mission

Add a Read Replica

Your primary database is running, but production workloads rarely hit a single instance. Add a read replica through your pipeline to offload read traffic, then verify replication is working in the AWS Console.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The RDS instances in this project are free tier eligible for 750 hours per month in aggregate, but running both a primary and replica for a full month exceeds that allowance.

Cost warning

The db.t4g.micro free tier covers 750 hours per month total across all RDS instances. Running both the primary and replica simultaneously uses approximately 1,500 hours per month, exceeding the free tier by roughly 750 hours. If your AWS account was created after July 15, 2025, costs are covered by the $100 sign-up credit until it runs out.

If you are not continuing to the next project immediately, delete your resources to avoid charges.

Resources you used:

  • RDS PostgreSQL primary instance (nextwork-rds-primary).
  • RDS Read Replica (nextwork-rds-replica).
  • VPC, subnets, security group, DB subnet group, and parameter group (no ongoing cost, but should be cleaned up).
  • GitLab project and remote Terraform state (free).
  • Local project folder (rds-terraform-pipeline).

Keep everything running

No action needed. Choose this if you plan to continue with a follow-up project within a few days.

  • Monitor your costs in the AWS Billing console to make sure you stay within free tier limits.
  • If you completed the Secret Mission, remember that running both the primary and replica counts double against your 750 hour monthly allowance.
  • Your GitLab project and Terraform state remain available at no cost.

Pause - I'll come back to this later

RDS does not support stopping instances on the db.t4g.micro free tier class. The stop/start feature is only available for larger, non-free-tier instance classes. To pause costs, you need to destroy the resources and redeploy later using your pipeline.

  • Follow the steps in the Delete tab below to remove your AWS resources.
  • Keep your local project files and GitLab repository intact.
  • When you are ready to resume, push a commit to the main branch and manually approve the apply job. Your pipeline will recreate all resources from scratch.

Delete - I don't want to use this again

Remove all project resources and start fresh. This destroys your RDS instances, VPC networking, and all associated AWS resources.

Step 1: Destroy the AWS resources using Terraform.

  • Open a terminal in VS Code and navigate to your rds-terraform-pipeline folder.
  • Set the HTTP backend environment variables so Terraform can connect to your GitLab-managed state. Replace the placeholder values with your own GitLab project ID, username, and personal access token.
$env:TF_HTTP_ADDRESS = "https://gitlab.com/api/v4/projects/[[GITLAB_PROJECT_ID="your GitLab project ID number"]]/terraform/state/default"
$env:TF_HTTP_LOCK_ADDRESS = "https://gitlab.com/api/v4/projects/[[GITLAB_PROJECT_ID="your GitLab project ID number"]]/terraform/state/default/lock"
$env:TF_HTTP_UNLOCK_ADDRESS = "https://gitlab.com/api/v4/projects/[[GITLAB_PROJECT_ID="your GitLab project ID number"]]/terraform/state/default/lock"
$env:TF_HTTP_USERNAME = "your-gitlab-username"
$env:TF_HTTP_PASSWORD = "your-personal-access-token"
$env:TF_HTTP_LOCK_METHOD = "POST"
$env:TF_HTTP_UNLOCK_METHOD = "DELETE"

Where do I find my GitLab project ID?

Your project ID is displayed on the project overview page in GitLab. It appears as a number below the project name.

For the personal access token, create one in GitLab under your user settings with the api scope. This token lets Terraform authenticate with GitLab's state backend from your local machine.

  • Initialize Terraform with the remote backend by running:
terraform init
  • Destroy all AWS resources by running:
terraform destroy
  • When prompted, type yes and press Enter to confirm the destruction.
  • Wait for Terraform to finish. It takes a few minutes to delete the RDS instances.

Step 2: Verify resources are gone.

  • Open the AWS Console and navigate to the RDS service.
  • Confirm that nextwork-rds-primary and nextwork-rds-replica no longer appear in the database list.
  • Navigate to the VPC service and confirm that nextwork-rds-vpc has been removed.

Step 3: Delete local project files.

  • Delete the local project folder by running:
cd ~\Desktop
Remove-Item -Recurse -Force rds-terraform-pipeline

Step 4: Delete the GitLab project (optional).

  • In GitLab, open your rds-terraform-pipeline project.
  • Select Settings in the left sidebar, then General.
  • Expand the Advanced section.
  • In the Delete project section, select Delete.
  • Enter the project name to confirm and select Yes, delete project.

What about GitLab's deletion delay?

On GitLab.com, deleted projects enter a 30-day pending deletion period before permanent removal. Your Terraform state and CI/CD history are removed along with the project. Since GitLab's free tier has no storage costs, this delay does not affect your bill.

That's a Wrap!

That's a Wrap!

Nice work! You just deployed a production-style RDS PostgreSQL database entirely through Terraform code and a GitLab CI/CD pipeline.

You've learned how to:

  • Write Terraform code that provisions a complete AWS networking layer (VPC, subnets, security group) and an RDS PostgreSQL database with a custom parameter group.
  • Build a GitLab CI/CD pipeline with validate, plan, and manual-approval apply stages using GitLab-managed remote state.
  • Deploy real infrastructure to AWS through a CI/CD pipeline following industry best practices: remote state locking, secrets management via AWS Secrets Manager, manual approval gates, and a plan-then-apply workflow.
  • Secret Mission: Added a read replica through the pipeline, demonstrating how infrastructure changes flow through code review and approval before touching production.

Ready to quiz yourself?