Como criar um bastion host para acessar seu banco de dados em uma sub-rede privada da AWS com Terraform
Intro
Eu estava trabalhando em um projeto pessoal para aprender sobre Terraform e a nuvem AWS. Eu já havia criado alguns projetos usando o AWS RDS, mas nunca me aprofundei nas configurações e práticas recomendadas — como criar um proxy RDS, configurar grupos de segurança ou colocar meu banco de dados em uma sub-rede privada. Com este projeto, decidi me aprofundar nesses aspectos enquanto aprendia sobre Terraform.
Agora estou aqui para dar um tutorial rápido sobre como criar um bastion host para acessar seu banco de dados. Vamos começar explicando alguns termos que usarei.
Observações
O post principal está no Medium e no Linkedin porém eu não podia deixar de postar aqui no TabNews. Porém aqui não consigo deixar imagens e tenho um limite de 20000 letras. Tentei compactar o máximo possivel mas algumas partes ficaram faltando então para quem tiver mais interesse no assunto basta visitar os links acima.
O que é uma Nuvem Privada Virtual (VPC)?
Como o nome sugere, uma VPC é uma rede virtual que você pode criar e gerenciar dentro de uma nuvem — no nosso caso, a AWS. Por exemplo, você pode criar sub-redes, regras de segurança e executar instâncias dentro dessa rede.
O que é uma sub-rede?
Sub-redes são um segmento dentro da VPC, a ideia é a mesma quando pensamos em dividir, por exemplo, a rede 192.168.1.0 com a máscara 255.255.255.0 (/24) em 2 sub-redes 192.168.1.0 e 192.168.1.128 com a máscara 255.255.255.128 (/25).
Na AWS, podemos designar essas sub-redes como públicas ou privadas. Uma sub-rede pública tem acesso à internet e pode ser acessada pela internet; uma sub-rede privada, não.
Então, se a sub-rede privada não estiver acessível pela internet, como podemos nos conectar ao nosso banco de dados para executar comandos, realizar migrações ou visualizar dados — mantendo o banco de dados seguro em uma sub-rede privada? É aí que entra o bastion host.
O que é um bastion host?
Basicamente, um bastion host é um servidor — normalmente em uma sub-rede pública — que tem acesso a uma sub-rede privada. Ele fornece a usuários controlados e autorizados acesso aos recursos da sub-rede privada, em vez de expor esses recursos diretamente.
No nosso caso, o bastião atuará como um túnel para o banco de dados. Ele fica em uma sub-rede pública, então podemos nos conectar via tunelamento SSH.
O que são Grupos de Segurança?
Os Grupos de Segurança atuam como um firewall virtual, permitindo que você decida qual tráfego é permitido ou negado aos seus recursos — por exemplo, permitindo conexões SSH ao seu bastion host.
O que é Terraform?
Vou pegar a citação do site do Terraform e colá-la aqui porque ela explica melhor do que eu:
O Terraform é uma ferramenta de infraestrutura como código que permite construir, alterar e versionar infraestruturas com segurança e eficiência. Isso inclui componentes de baixo nível, como instâncias de computação, armazenamento e rede; e componentes de alto nível, como entradas de DNS e recursos de SaaS.
Assim, com o Terraform, podemos criar, modificar e destruir todos os nossos recursos com apenas alguns comandos e linhas de código.
Pre-requisites
- Terraform instalado em sua máquina
- Conta AWS
- ID da conta AWS para configurar o Terraform
- Um perfil AWS com permissões para gerenciar VPCs, Grupos de Segurança e instâncias RDS
- Conhecimento básico do Terraform para entender os arquivos .tf
Nosso diretório de trabalho
├── db-bastion.tf
├── get-bastion-key.sh
├── main.tf
├── modules
│ ├── db-bastion
│ │ ├── bastion.tf
│ │ ├── main.tf
│ │ ├── output.tf
│ │ ├── variables.tf
│ │ └── versions.tf
│ ├── rds
│ │ ├── outputs.tf
│ │ ├── rds-postgres.tf
│ │ ├── secret-manager.tf
│ │ ├── variables.tf
│ │ └── versions.tf
│ ├── security-group
│ │ ├── bastion.tf
│ │ ├── outputs.tf
│ │ ├── private.tf
│ │ ├── public.tf
│ │ ├── variables.tf
│ │ └── versions.tf
│ └── vpc
│ ├── outputs.tf
│ ├── private.tf
│ ├── public.tf
│ ├── variables.tf
│ ├── versions.tf
│ └── vpc.tf
├── rds.tf
├── security-group.tf
├── terraform.tfstate
├── terraform.tfvars
├── variables.tf
├── versions.tf
└── vpc.tf
Temos uma pasta de módulos contendo os módulos que criamos para organizar nossos recursos do Terraform. Para concluir o tutorial, criaremos uma VPC com sub-redes, um banco de dados RDS Postgres, uma instância EC2 Bastion e grupos de segurança. Pense nos módulos como arquivos ou bibliotecas em JavaScript que você importa ou precisa. Também temos os arquivos principais do Terraform, como: main.tf, versions.tf, variables.tf e terraform.tfvars.
Configuração base do Terraform
No diretório raiz temos arquivos como:
versions.tf que definem os provedores que usaremos no Terraform. O Terraform fornece muitos provedores — Azure, Google Cloud, Cloudflare, etc. — que permitem que você gerencie a infraestrutura nessas plataformas.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf, onde definimos a configuração base para usar o provedor da AWS no Terraform. Basicamente, passamos a região onde nossos recursos serão criados e especificamos qual perfil da AWS usaremos para executar essas ações. Aqui, você precisa fornecer um perfil com as permissões corretas para criar recursos, caso não esteja usando o perfil root.
provider "aws" {
region = "eu-west-1"
profile = "profile-with-permissions"
}
variables.tf onde definimos as variáveis que usaremos nos arquivos do Terraform
variable "aws_region" {
description = "AWS Region"
type = string
}
variable "aws_account_id" {
type = string
description = "AWS Account ID"
}
variable "master_password" {
description = "Database master password"
type = string
sensitive = true
}
variable "master_username" {
description = "Database master username"
type = string
}
terraform.tfvars este arquivo é onde colocamos as variáveis que foram definidas no arquivo variables.tf
aws_region = "eu-west-1"
aws_account_id = "YOUR-AWS-ACCOUNT-ID"
master_username = "postgres"
master_password = "examplePassword"
Após todos esses arquivos serem definidos, podemos começar com os arquivos de recursos e módulos.
Configuração do Terraform VPC
Na raiz do nosso projeto, criamos um novo arquivo chamado vpc.tf com este conteúdo
module "vpc" {
source = "./modules/vpc"
aws_region = var.aws_region
availability_zones = ["${var.aws_region}a", "${var.aws_region}b", "${var.aws_region}c"]
}
Aqui podemos ver como usar as variáveis definidas em nosso arquivo terraform.tfvars, basicamente usamos ${var.VAR_NAME}.
Agora dentro de ./modules/vpc temos os seguintes arquivos:
vpc.tf aqui definimos nosso recurso VPC com algumas configurações
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
}
public.tf aqui definimos um gateway de internet para dar acesso à internet às nossas sub-redes públicas, uma tabela de rotas para enviar todo o tráfego de saída para nosso gateway de internet e nossas sub-redes públicas
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
}
resource "aws_subnet" "first_public" {
vpc_id = aws_vpc.this.id
cidr_block = var.first_public_subnet_cidr
availability_zone = var.availability_zones[0]
map_public_ip_on_launch = true tags = {
subnet = "first_public"
}
}
resource "aws_subnet" "second_public" {
vpc_id = aws_vpc.this.id
cidr_block = var.second_public_subnet_cidr
availability_zone = var.availability_zones[1]
map_public_ip_on_launch = true tags = {
subnet = "second_public"
}
}
resource "aws_route_table" "public_route_table" {
vpc_id = aws_vpc.this.id route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
} tags = {
subnet = "public_route_table"
}
}
resource "aws_route_table_association" "first_public" {
subnet_id = aws_subnet.first_public.id
route_table_id = aws_route_table.public_route_table.id
}
resource "aws_route_table_association" "second_public" {
subnet_id = aws_subnet.second_public.id
route_table_id = aws_route_table.public_route_table.id
}
private.tf aqui definimos nossas sub-redes privadas, não precisamos de um gateway de internet ou tabela de rotas porque elas não deveriam ter acesso à internet
resource "aws_subnet" "first_private" {
vpc_id = aws_vpc.this.id
cidr_block = var.first_private_subnet_cidr
availability_zone = var.availability_zones[0]
tags = {
subnet = "first_private"
}
}
resource "aws_subnet" "second_private" {
vpc_id = aws_vpc.this.id
cidr_block = var.second_private_subnet_cidr
availability_zone = var.availability_zones[1] tags = {
subnet = "second_private"
}
}
Configuração do Terraform RDS
na raiz do nosso diretório temos o rds.tf que passa algumas configurações para o módulo rds e a saída que o Terraform vai nos dar depois de executá-lo
module "rds" {
source = "./modules/rds"
availability_zones = ["${var.aws_region}a", "${var.aws_region}b", "${var.aws_region}c"] identifier = "postgres"
engine_version = "16.8"
username = var.master_username
password = var.master_password
allocated_storage = 20
instance_class = "db.t4g.micro"
storage_encrypted = false
publicly_accessible = false
skip_final_snapshot = true
multi_az = false
backup_retention_period = 7
backup_window = "00:00-00:30"
maintenance_window = "sun:05:00-sun:05:30"
copy_tags_to_snapshot = true
availability_zone = "${var.aws_region}a"
apply_immediately = true
prevent_destroy = false vpc_security_group_ids = [module.security_group.vpc_private_sg_id]
subnet_ids = [module.vpc.first_private_subnet_id, module.vpc.second_private_subnet_id]
}output "rds_instance_endpoint" {
description = "RDS PostgreSQL instance endpoint"
value = module.rds.instance_endpoint
}
dentro da pasta do módulo rds:
rds.tf aqui definimos nosso banco de dados e o grupo de sub-rede para ele
resource "aws_db_subnet_group" "postgres" {
name = "${var.identifier}-subnet-group"
subnet_ids = var.subnet_ids
lifecycle {
create_before_destroy = true
}
}
resource "aws_db_instance" "postgres_instance" {
allocated_storage = var.allocated_storage
engine = "postgres"
engine_version = var.engine_version
instance_class = var.instance_class
identifier = var.identifier
username = var.username
password = var.password
storage_encrypted = var.storage_encrypted
publicly_accessible = var.publicly_accessible
skip_final_snapshot = var.skip_final_snapshot
db_subnet_group_name = aws_db_subnet_group.postgres.name
vpc_security_group_ids = var.vpc_security_group_ids
multi_az = var.multi_az
availability_zone = var.availability_zone backup_retention_period = var.backup_retention_period
backup_window = var.backup_window
maintenance_window = var.maintenance_window copy_tags_to_snapshot = var.copy_tags_to_snapshot
apply_immediately = var.apply_immediately deletion_protection = var.prevent_destroy tags = var.tags
}
secret-manager.tf aqui salvamos nossas credenciais de banco de dados no gerenciador de segredos da AWS com um nome aleatório
resource "random_integer" "secret_suffix" {
min = 1000000000000
max = 9999999999999
}
resource "random_pet" "secret_suffix" {
length = 2
separator = "-"
}
resource "aws_secretsmanager_secret" "db_secret" {
name = "db-postgres-secret-for-rds-test-${random_integer.secret_suffix.result}-${random_pet.secret_suffix.id}"
description = "Secret with the database credentials"
}
resource "aws_secretsmanager_secret_version" "db_secret_version" {
secret_id = aws_secretsmanager_secret.db_secret.id
secret_string = jsonencode({
username = var.username,
password = var.password,
engine = aws_db_instance.postgres_instance.engine,
host = aws_db_instance.postgres_instance.address,
port = aws_db_instance.postgres_instance.port,
dbInstanceIdentifier = aws_db_instance.postgres_instance.identifier
})
}
Configuração do Terraform Bastion
Na raiz do projeto temos o db-bastion.tf onde passamos as variáveis para o módulo e geramos alguns valores que precisaremos mais tarde
module "db_bastion" {
source = "./modules/db-bastion"
vpc_id = module.vpc.vpc_id
subnet_id = module.vpc.first_public_subnet_id
bastion_sg_id = module.security_group.bastion_sg_id
bastion_instance_connect_endpoint_sg_id = module.security_group.bastion_instance_connect_endpoint_sg_id
}
dentro da pasta do módulo db-bastion:
bastion.tf aqui definimos uma chave privada e pública que usaremos para conectar em nossa máquina via ssh, uma máquina com imagem de servidor Ubuntu e a própria máquina ec2.
resource "tls_private_key" "tls_private_key" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "aws_key_pair" "generated_key" {
key_name = "bastion-key-test"
public_key = tls_private_key.tls_private_key.public_key_openssh
}
data "aws_ami" "ubuntu" {
most_recent = true filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
} filter {
name = "virtualization-type"
values = ["hvm"]
} owners = ["099720109477"]
}
resource "aws_iam_role" "bastion_role" {
name = "bastion-role-test" assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
}) tags = {
Name = "bastion-role-test"
Terraform = "true"
}
}
resource "aws_instance" "bastion" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
subnet_id = var.subnet_id
vpc_security_group_ids = [var.bastion_sg_id]
associate_public_ip_address = true
key_name = aws_key_pair.generated_key.key_name tags = {
Name = "bastion-test"
Terraform = "true"
}
}
resource "aws_ec2_instance_connect_endpoint" "instance_connect_endpoint" {
subnet_id = var.subnet_id
security_group_ids = [var.bastion_instance_connect_endpoint_sg_id]
preserve_client_ip = false tags = {
Name = "bastion-connect-test"
Terraform = "true"
}
}
Agora tudo está configurado no Terraform. Vamos rodar e ver como funciona.
Executando o Terraform
No módulo raiz, executamos
terraform init
O Terraform irá configurar nosso ambiente e então executaremos
terraform plan
com esse comando o terraform vai criar um plano de implantação e nos dará tudo o que ele vai fazer.
No output podemos ver que o Terraform criará 36 recursos na AWS e nos enviará algumas informações. "(Know after apply)" significa que o Terraform não sabe, por enquanto, o endpoint ou IP dos nossos recursos, pois eles ainda não existem. "(sensitive value)" significa que configuramos este valor como confidencial e não deve ser registrado no console.
Agora a gente executa:
terraform apply
O Terraform fará o plano novamente e perguntará se você realmente deseja executá-lo. Quando a pergunta for feita, você pode digitar "sim" e pressionar Enter. A aplicação leva cerca de 5 minutos para ser concluída.
Agora que temos nosso IP Bastion, podemos nos conectar a ele, mas primeiro precisamos obter nossa private_key para usar em nossa conexão SSH.
Podemos criar um script de shell simples para recuperá-lo
#!/bin/bash
rm -rf .db_bastion_private_key.pem
terraform output -raw db_bastion_private_key > .db_bastion_private_key.pem
chmod 400 .db_bastion_private_key.pem
Este script excluirá a chave se ela já existir em nosso diretório raiz, executará o terraform para gerá-la e gravará em um arquivo chamado: .db_bastion_private_key.pem.
Agora, depois de executar o script acima, podemos criar um túnel executando:
ssh -L 5432:postgres.c10ygmeey4rg.eu-west-1.rds.amazonaws.com:5432 -i .db_bastion_private_key.pem ubuntu@3.253.76.168
não esqueça de substituir o ip e o endpoint rds
Agora, estamos dentro do nosso Bastion e todas as conexões com o host local na porta 5432 serão encaminhadas através do túnel SSH para o ponto de extremidade RDS.
Quando o túnel estiver ativo, qualquer cliente que se conectar a localhost:5432 falará com postgres.c10ygmeey4rg.eu-west-1.rds.amazonaws.com:5432 por meio do bastião SSH.
Podemos verificar isso tentando conectar ao nosso banco de dados, vou usar o DBeaver, mas você pode usar o que quiser.
Você pode usar isso para executar migrações no seu banco de dados a partir do computador local, por exemplo.
Destruindo tudo
Agora precisamos destruir nossos recursos para evitar uma cobrança assustadora na AWS.
Basta executar
terraform destroy
e digite "sim" quando o Terraform perguntar.
Conclusão
O Terraform (ou outras ferramentas que façam o mesmo) é uma ferramenta indispensável ao trabalhar com nuvens. Você pode controlar tudo com código e manter um histórico de modificações quando integrado ao GitHub, como neste artigo, onde conseguimos criar uma VPC, grupos de segurança, banco de dados e um bastion host com poucas configurações.
O projeto completo está no repositório do GitHub, que você pode conferir: https://github.com/HenriqueRamos13/bastion_host_on_AWS_with_terraform
Obrigado pela leitura e bom código!