High Availability Terraform Template
Overview
You can deploy SFTP Gateway version 3.x using Terraform, as an alternative to CloudFormation.
This article covers deploying an HA stack of SFTP Gateway Professional version 3.7.3
on AWS. Resources are deployed into a new VPC.
The Terraform template is provided as an example, so feel free to further customize it for your business case.
Note: Make sure you are subscribed to SFTP Gateway in the AWS Marketplace. Otherwise, your AWS account will not be authorized to deploy any product AMIs without a subscription.
Running the template
This article contains three files:
sftpgw-ha.tf
user_data.yaml
terraform.tfvars
Create these three files on your workstation, using the file contents at the bottom of this page. Make adjustments to the terraform.tfvars
file. Then, run the following commands:
terraform init
terraform plan
When you are ready to deploy the template, run:
terraform apply
How does it work
This article contains a main Terraform template named:
sftpgw-ha.tf
This template provisions the following resources:
VPC
: This is a new network, including subnets and route tablesNLB
: A network load balancer, including listeners and target groupsASG
: An AutoScaling Group, along with Launch TemplateIAM role
: Grants EC2 instances access to S3 as well as authenticating to RDSRDS
: A PostgreSQL 16 database serviceSecrets Manager
: Stores the database passwordCloudwatch Log Group
: Stores log dataEC2 Security Group
: Allows TCP22
from anywhere, but locks down admin ports80
,443
,2222
to a single incoming IP range
There's another file that you need, which configures the EC2 instances in HA mode:
user_data.yaml
Finally, there's a third file that contains variables:
terraform.tfvars
Since this file is named terraform.tfvars
, it will be automatically used without having to run:
terraform -var-file terraform.tfvars
You can configure the following variables:
stack_name
: Specify the name of your Terraform stack.key_name
: Specify the name of your EC2 key pair.region
: Specify your current region.input_cidr
: For Web & SSH access (port 80, 443, 2222). You can obtain your workstation's public IP from ipchicken. Append/32
to specify a single IP range.sftp_input_cidr
: same as the input_cidr but for SFTP access (port 22).bucket_access
: Set this toopen
for full S3 permissions. Userestricted
to restrict permissions to buckets with the naming conventionsftpgw-i-*
.web_admin_username
: Optional. Username for the web admin interface.web_admin_password
: Optional. Password for the web admin interface (minimum 12 characters)ec2_type
: Optional. Defaults tot3.medium
. Use this to override the EC2 instance size.disk_volume_size
: Optional. Defaults to32
. Use this to override the EC2 disk size, in GB.desired_capacity
: Optional. Defaults to2
. The number of EC2 instances to deploy in your AutoScaling Group.db_class
: Optional. Defaults todb.t3.micro
. The size of your RDS instance.vpc_ip_range
: Optional. Defaults to192.168.1.0/24
. Set this to a Class C private IP range.
Using the AWS CloudShell
The AWS CloudShell
does not come with Terraform installed by default. So you will need to download and install it manually.
wget https://releases.hashicorp.com/terraform/1.9.4/terraform_1.9.4_linux_amd64.zip
unzip terraform_1.9.4_linux_amd64.zip
mkdir ~/bin
mv terraform ~/bin
rm -f terraform_1.9.4_linux_amd64.zip
(Special thanks to this online article for these instructions https://blog.clairvoyantsoft.com/aws-cloudshell-and-terraform-18eb8b41041f)
Terraform file contents
user_data.yml
#cloud-config
repo_update: true
repo_upgrade: all
packages:
- jq
write_files:
- content: |
#!/bin/bash
export CLOUD_PROVIDER=aws
export ARCHITECTURE=HA
export LOG_GROUP_NAME=${log_group_name}
export SECRET_ID=${secret_id}
export DB_HOST=${db_host}
path: /opt/sftpgw/launch_config.env
permissions: '0644'
- content: |
${web_admin_password}
path: /tmp/admin_pwd.txt
permissions: '0600'
- content: |
${web_admin_username}
path: /tmp/admin_user.txt
permissions: '0600'
runcmd:
- |
# Use the values from files (written by cloud-init)
if [ -s /tmp/admin_pwd.txt ] && [ -s /tmp/admin_user.txt ]; then
# Trim newlines from the file content
PASSWORD=$(cat /tmp/admin_pwd.txt | tr -d '\n')
USERNAME=$(cat /tmp/admin_user.txt | tr -d '\n')
PAYLOAD=$(jq -n \
--arg pwd "$PASSWORD" \
--arg user "$USERNAME" \
'{"password": $pwd, "username": $user}')
echo "Sending admin config payload"
curl -X "POST" "http://localhost:8080/3.0.0/admin/config" \
-H "accept: */*" \
-H "Content-Type: application/json" \
-d "$PAYLOAD"
# Clean up the temporary files
rm -f /tmp/admin_pwd.txt /tmp/admin_user.txt
fi
sftpgw-ha.tf
# SFTP Gateway High Availability - Terraform Template
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.1"
}
}
}
provider "aws" {
region = var.aws_region
}
# Data sources
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
# Variables
variable "aws_region" {
description = "AWS region for deployment"
type = string
default = "us-east-1"
}
# Region AMI mapping for SFTP Gateway
variable "ami_map" {
description = "Regional AMI map for SFTP Gateway"
type = map(string)
default = {
us-east-1 = "ami-0ffe3b0526f26eaba"
us-west-2 = "ami-07a2f1e62eb802b4b"
us-west-1 = "ami-038da870d6e9cb776"
us-east-2 = "ami-0a01cbba1e3fba58f"
sa-east-1 = "ami-003bb206180379393"
me-south-1 = "ami-05b97a703196ded67"
me-central-1 = "ami-056466afefd20a237"
il-central-1 = "ami-0819169de02ff1296"
eu-west-3 = "ami-01d8dc4760e4010d2"
eu-west-2 = "ami-0027fee0d09bda434"
eu-west-1 = "ami-0b7e268e93be7488d"
eu-south-2 = "ami-03fc7c114d3302755"
eu-south-1 = "ami-016304b4e81ca2d18"
eu-north-1 = "ami-0e3a321349b8c35b3"
eu-central-2 = "ami-0ec39f8978b4c21db"
eu-central-1 = "ami-0054795cf2c11758d"
ca-central-1 = "ami-0023a6a2563427e07"
ap-southeast-4 = "ami-097a58688840f2885"
ap-southeast-3 = "ami-05c7ef9b40cb6a045"
ap-southeast-2 = "ami-00d5bc803d7cc59fd"
ap-southeast-1 = "ami-01bf1d3bd55b132f9"
ap-south-2 = "ami-0f3121642ed1b679a"
ap-south-1 = "ami-0dd31922b45f6364f"
ap-northeast-3 = "ami-0ddf6c7ba33fe4276"
ap-northeast-2 = "ami-01b86b13fc6d4a7c5"
ap-northeast-1 = "ami-00b1c5eaea281c4c9"
ap-east-1 = "ami-0399cffe9ff3328d1"
af-south-1 = "ami-07f0fb26a479bafd2"
us-gov-east-1 = "ami-0c9b157428f47d5ef"
us-gov-west-1 = "ami-04f084372af620942"
}
}
variable "image_id" {
description = "AMI ID for the SFTP Gateway instance (leave empty to use regional mapping)"
type = string
default = ""
}
variable "ec2_type" {
description = "SFTP Gateway instance type"
type = string
default = "t3.medium"
validation {
condition = contains([
"t2.micro", "t2.small", "t2.medium", "t2.large", "t2.xlarge", "t2.2xlarge",
"t3.micro", "t3.small", "t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge",
"m3.medium", "m3.large", "m3.xlarge", "m3.2xlarge",
"m4.large", "m4.xlarge", "m4.2xlarge", "m4.4xlarge", "m4.10xlarge", "m4.16xlarge",
"m5.large", "m5.xlarge", "m5.2xlarge", "m5.4xlarge", "m5.8xlarge", "m5.12xlarge", "m5.16xlarge", "m5.24xlarge",
"c4.large", "c4.xlarge", "c4.2xlarge", "c4.4xlarge", "c4.8xlarge",
"c5.large", "c5.xlarge", "c5.2xlarge", "c5.4xlarge", "c5.9xlarge", "c5.12xlarge", "c5.18xlarge", "c5.24xlarge",
"r3.large", "r3.xlarge", "r3.2xlarge", "r3.4xlarge", "r3.8xlarge",
"r4.large", "r4.xlarge", "r4.2xlarge", "r4.4xlarge", "r4.8xlarge", "r4.16xlarge",
"r5.large", "r5.xlarge", "r5.2xlarge", "r5.4xlarge", "r5.8xlarge", "r5.12xlarge", "r5.16xlarge", "r5.24xlarge",
"r5d.large", "r5d.xlarge", "r5d.2xlarge", "r5d.4xlarge", "r5d.8xlarge", "r5d.12xlarge", "r5d.16xlarge", "r5d.24xlarge"
], var.ec2_type)
error_message = "EC2 instance type must be a valid instance type."
}
}
variable "vpc_ip_range" {
description = "Class C IP range for VPC"
type = string
default = "192.168.1.0/24"
validation {
condition = contains([
"192.168.1.0/24", "192.168.2.0/24", "192.168.3.0/24", "192.168.4.0/24", "192.168.5.0/24",
"192.168.6.0/24", "192.168.7.0/24", "192.168.8.0/24", "192.168.9.0/24", "192.168.10.0/24",
"192.168.11.0/24", "192.168.12.0/24", "192.168.13.0/24", "192.168.14.0/24", "192.168.15.0/24",
"192.168.16.0/24", "192.168.17.0/24", "192.168.18.0/24", "192.168.19.0/24", "192.168.20.0/24"
], var.vpc_ip_range)
error_message = "VPC IP range must be a valid Class C range."
}
}
variable "disk_volume_size" {
description = "Disk volume size in GB"
type = number
default = 32
validation {
condition = var.disk_volume_size >= 32
error_message = "Disk volume size must be at least 32 GB."
}
}
variable "input_cidr" {
description = "Public IP address range for SSH and web access"
type = string
validation {
condition = can(cidrhost(var.input_cidr, 0))
error_message = "Must be a valid IP CIDR range in the form of x.x.x.x/x."
}
}
variable "sftp_input_cidr" {
description = "Public IP address range for SFTP access"
type = string
validation {
condition = can(cidrhost(var.sftp_input_cidr, 0))
error_message = "Must be a valid IP CIDR range in the form of x.x.x.x/x."
}
}
variable "db_class" {
description = "DB instance type"
type = string
default = "db.t3.micro"
validation {
condition = contains([
"db.t3.micro", "db.t3.small", "db.t3.medium", "db.m5.large"
], var.db_class)
error_message = "Must select a valid database instance type."
}
}
variable "desired_capacity" {
description = "Desired capacity for Auto Scaling Group"
type = number
default = 2
}
variable "web_admin_username" {
description = "Username for web admin interface"
type = string
default = ""
validation {
condition = can(regex("^[^\"\\/:*<>?|]*$", var.web_admin_username)) || var.web_admin_username == ""
error_message = "Username must not contain \", \\, /, :, *, <, >, ?, or |"
}
}
variable "web_admin_password" {
description = "Password for web admin interface"
type = string
default = ""
sensitive = true
validation {
condition = length(var.web_admin_password) >= 12 || var.web_admin_password == ""
error_message = "Password must be 12 or more characters in length."
}
}
variable "key_pair" {
description = "EC2 Key Pair name"
type = string
}
variable "bucket_access" {
description = "S3 bucket access type"
type = string
default = "restricted"
validation {
condition = contains([
"open", "restricted"
], var.bucket_access)
error_message = "Bucket access must be either 'open' or 'restricted'."
}
}
variable "stack_name" {
description = "Name for the deployment stack"
type = string
default = "sftpgw-ha"
}
# Local values
locals {
common_tags = {
Project = "SFTP-Gateway"
Environment = "Staging"
ManagedBy = "Terraform"
}
}
# VPC and Networking
resource "aws_vpc" "main" {
cidr_block = var.vpc_ip_range
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(local.common_tags, {
Name = "${var.stack_name}-vpc"
})
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(local.common_tags, {
Name = "${var.stack_name}-igw"
})
}
# Public Subnets
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_ip_range, 4, 0)
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = merge(local.common_tags, {
Name = "${var.stack_name}-public-subnet-a"
})
}
resource "aws_subnet" "public_b" {
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_ip_range, 4, 1)
availability_zone = data.aws_availability_zones.available.names[1]
map_public_ip_on_launch = true
tags = merge(local.common_tags, {
Name = "${var.stack_name}-public-subnet-b"
})
}
# Private Subnets (Database)
resource "aws_subnet" "private_a" {
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_ip_range, 4, 2)
availability_zone = data.aws_availability_zones.available.names[0]
tags = merge(local.common_tags, {
Name = "${var.stack_name}-private-subnet-a"
})
}
resource "aws_subnet" "private_b" {
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_ip_range, 4, 3)
availability_zone = data.aws_availability_zones.available.names[1]
tags = merge(local.common_tags, {
Name = "${var.stack_name}-private-subnet-b"
})
}
# Private Subnets (Application)
resource "aws_subnet" "private_c" {
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_ip_range, 4, 4)
availability_zone = data.aws_availability_zones.available.names[0]
tags = merge(local.common_tags, {
Name = "${var.stack_name}-private-subnet-c"
})
}
resource "aws_subnet" "private_d" {
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_ip_range, 4, 5)
availability_zone = data.aws_availability_zones.available.names[1]
tags = merge(local.common_tags, {
Name = "${var.stack_name}-private-subnet-d"
})
}
# NAT Gateway
resource "aws_eip" "nat" {
domain = "vpc"
tags = merge(local.common_tags, {
Name = "${var.stack_name}-nat-eip"
})
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_a.id
tags = merge(local.common_tags, {
Name = "${var.stack_name}-nat-gateway"
})
depends_on = [aws_internet_gateway.main]
}
# Route Tables
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = merge(local.common_tags, {
Name = "${var.stack_name}-public-rt"
})
}
resource "aws_route_table" "private_db" {
vpc_id = aws_vpc.main.id
tags = merge(local.common_tags, {
Name = "${var.stack_name}-private-db-rt"
})
}
resource "aws_route_table" "private_app_1" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = merge(local.common_tags, {
Name = "${var.stack_name}-private-app-rt-1"
})
}
resource "aws_route_table" "private_app_2" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = merge(local.common_tags, {
Name = "${var.stack_name}-private-app-rt-2"
})
}
# Route Table Associations
resource "aws_route_table_association" "public_a" {
subnet_id = aws_subnet.public_a.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_b" {
subnet_id = aws_subnet.public_b.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private_a" {
subnet_id = aws_subnet.private_a.id
route_table_id = aws_route_table.private_db.id
}
resource "aws_route_table_association" "private_b" {
subnet_id = aws_subnet.private_b.id
route_table_id = aws_route_table.private_db.id
}
resource "aws_route_table_association" "private_c" {
subnet_id = aws_subnet.private_c.id
route_table_id = aws_route_table.private_app_1.id
}
resource "aws_route_table_association" "private_d" {
subnet_id = aws_subnet.private_d.id
route_table_id = aws_route_table.private_app_2.id
}
# Security Groups
resource "aws_security_group" "sftpgw" {
name_prefix = "${var.stack_name}-sftpgw-"
description = "SFTP Gateway Security Group"
vpc_id = aws_vpc.main.id
# SFTP access
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.sftp_input_cidr]
}
# SSH admin access
ingress {
from_port = 2222
to_port = 2222
protocol = "tcp"
cidr_blocks = [var.input_cidr]
}
# Web access
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = [var.input_cidr]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.input_cidr]
}
# VPC internal access
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.vpc_ip_range]
}
ingress {
from_port = 2222
to_port = 2222
protocol = "tcp"
cidr_blocks = [var.vpc_ip_range]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = [var.vpc_ip_range]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.vpc_ip_range] # This allows NLB health checks
}
# Outbound access
egress {
from_port = 0
to_port = 65535
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 65535
protocol = "udp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(local.common_tags, {
Name = "${var.stack_name}-sftpgw-sg"
})
}
resource "aws_security_group" "rds" {
name_prefix = "${var.stack_name}-rds-"
description = "Security group for RDS DB Instance"
vpc_id = aws_vpc.main.id
# Add outbound rule for RDS
egress {
from_port = 0
to_port = 65535
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(local.common_tags, {
Name = "${var.stack_name}-rds-sg"
})
}
resource "aws_security_group_rule" "rds_ingress" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
source_security_group_id = aws_security_group.sftpgw.id
security_group_id = aws_security_group.rds.id
}
# Database Secrets
resource "random_password" "db_password" {
length = 30
special = false
upper = true
lower = true
numeric = true
}
resource "aws_secretsmanager_secret" "db_secret" {
name = "${var.stack_name}-pro-sftpgw-db-secret-${formatdate("YYYYMMDD-hhmm", timestamp())}"
description = "Dynamically generated secret password for SFTP Gateway database access"
tags = merge(local.common_tags, {
Name = "SFTP Gateway DB Secret"
})
}
resource "aws_secretsmanager_secret_version" "db_secret" {
secret_id = aws_secretsmanager_secret.db_secret.id
secret_string = jsonencode({
username = "sftpgw"
password = random_password.db_password.result
})
}
# RDS Database
resource "aws_db_subnet_group" "sftpgw" {
name = "${var.stack_name}-db-subnet-group"
subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_b.id]
tags = merge(local.common_tags, {
Name = "${var.stack_name}-db-subnet-group"
})
}
resource "aws_db_instance" "sftpgw" {
identifier = "${var.stack_name}-db"
db_name = "sftpgw"
allocated_storage = 50
storage_type = "gp2"
storage_encrypted = true
engine = "postgres"
engine_version = "16"
instance_class = var.db_class
username = "sftpgw"
password = random_password.db_password.result
db_subnet_group_name = aws_db_subnet_group.sftpgw.name
vpc_security_group_ids = [aws_security_group.rds.id]
multi_az = true
auto_minor_version_upgrade = true
publicly_accessible = false
skip_final_snapshot = true
iam_database_authentication_enabled = true
tags = merge(local.common_tags, {
Name = "SFTP Gateway Database"
})
}
# Load Balancer
resource "aws_eip" "lb" {
domain = "vpc"
tags = merge(local.common_tags, {
Name = "${var.stack_name}-lb-eip"
})
}
resource "aws_lb" "network" {
name = "${var.stack_name}-nlb"
internal = false
load_balancer_type = "network"
subnet_mapping {
subnet_id = aws_subnet.public_a.id
allocation_id = aws_eip.lb.id
}
subnet_mapping {
subnet_id = aws_subnet.public_b.id
# No allocation_id - let AWS assign dynamic IP
}
enable_cross_zone_load_balancing = true
tags = merge(local.common_tags, {
Name = "${var.stack_name}-nlb"
})
}
# Target Groups
resource "aws_lb_target_group" "sftp_22" {
name_prefix = "s22-"
port = 22
protocol = "TCP"
vpc_id = aws_vpc.main.id
health_check {
enabled = true
protocol = "HTTPS"
port = "443"
path = "/backend/actuator/health"
timeout = 10
interval = 30
healthy_threshold = 2
unhealthy_threshold = 2
}
preserve_client_ip = true
tags = merge(local.common_tags, {
Name = "${var.stack_name}-tg-22"
})
lifecycle {
create_before_destroy = true
}
}
resource "aws_lb_target_group" "ssh_2222" {
name_prefix = "ssh-"
port = 2222
protocol = "TCP"
vpc_id = aws_vpc.main.id
health_check {
enabled = true
protocol = "HTTPS"
port = "443"
path = "/backend/actuator/health"
timeout = 10
interval = 30
healthy_threshold = 2
unhealthy_threshold = 2
}
tags = merge(local.common_tags, {
Name = "${var.stack_name}-tg-2222"
})
lifecycle {
create_before_destroy = true
}
}
resource "aws_lb_target_group" "http_80" {
name_prefix = "h80-"
port = 80
protocol = "TCP"
vpc_id = aws_vpc.main.id
health_check {
enabled = true
protocol = "HTTPS"
port = "443"
path = "/backend/actuator/health"
timeout = 10
interval = 30
healthy_threshold = 2
unhealthy_threshold = 2
}
tags = merge(local.common_tags, {
Name = "${var.stack_name}-tg-80"
})
lifecycle {
create_before_destroy = true
}
}
resource "aws_lb_target_group" "https_443" {
name_prefix = "h443-"
port = 443
protocol = "TCP"
vpc_id = aws_vpc.main.id
health_check {
enabled = true
protocol = "HTTPS"
port = "443"
path = "/backend/actuator/health"
timeout = 10
interval = 30
healthy_threshold = 2
unhealthy_threshold = 2
}
tags = merge(local.common_tags, {
Name = "${var.stack_name}-tg-443"
})
lifecycle {
create_before_destroy = true
}
}
# Load Balancer Listeners
resource "aws_lb_listener" "sftp_22" {
load_balancer_arn = aws_lb.network.arn
port = "22"
protocol = "TCP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.sftp_22.arn
}
}
resource "aws_lb_listener" "ssh_2222" {
load_balancer_arn = aws_lb.network.arn
port = "2222"
protocol = "TCP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.ssh_2222.arn
}
}
resource "aws_lb_listener" "http_80" {
load_balancer_arn = aws_lb.network.arn
port = "80"
protocol = "TCP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.http_80.arn
}
}
resource "aws_lb_listener" "https_443" {
load_balancer_arn = aws_lb.network.arn
port = "443"
protocol = "TCP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.https_443.arn
}
}
# CloudWatch Log Group
resource "aws_cloudwatch_log_group" "sftpgw" {
name = "sftpgw-${var.stack_name}"
retention_in_days = 14
tags = merge(local.common_tags, {
Name = "${var.stack_name}-logs"
})
}
# IAM Roles and Policies
resource "aws_iam_role" "sftpgw_restricted" {
count = var.bucket_access == "restricted" ? 1 : 0
name = "${var.stack_name}-restricted-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
tags = local.common_tags
}
resource "aws_iam_role" "sftpgw_open" {
count = var.bucket_access == "open" ? 1 : 0
name = "${var.stack_name}-open-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
tags = local.common_tags
}
# IAM Policy Attachments - Replacing deprecated managed_policy_arns
resource "aws_iam_role_policy_attachment" "sftpgw_restricted_ssm" {
count = var.bucket_access == "restricted" ? 1 : 0
role = aws_iam_role.sftpgw_restricted[0].name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonEC2RoleforSSM"
}
resource "aws_iam_role_policy_attachment" "sftpgw_open_s3" {
count = var.bucket_access == "open" ? 1 : 0
role = aws_iam_role.sftpgw_open[0].name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonS3FullAccess"
}
resource "aws_iam_role_policy_attachment" "sftpgw_open_ssm" {
count = var.bucket_access == "open" ? 1 : 0
role = aws_iam_role.sftpgw_open[0].name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonEC2RoleforSSM"
}
resource "aws_iam_policy" "sftpgw" {
name = "${var.stack_name}-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetBucketLocation",
"s3:ListBucket",
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:CreateBucket",
"s3:PutBucketPublicAccessBlock"
]
Resource = "arn:${data.aws_partition.current.partition}:s3:::sftpgw-i-*"
},
{
Effect = "Allow"
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams",
"logs:CreateLogGroup",
"logs:GetLogEvents"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ec2:DescribeAvailabilityZones",
"ec2:DescribeInstances",
"ec2:DescribeTags"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"cloudformation:DescribeStacks",
"cloudformation:ListStackResources"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"secretsmanager:GetResourcePolicy",
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret",
"secretsmanager:ListSecretVersionIds"
]
Resource = aws_secretsmanager_secret.db_secret.arn
},
{
Effect = "Allow"
Action = [
"secretsmanager:ListSecrets"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"rds-db:*"
]
Resource = "arn:aws:rds-db:*:${data.aws_caller_identity.current.account_id}:dbuser:${aws_db_instance.sftpgw.resource_id}/sftpgw"
}
]
})
}
resource "aws_iam_role_policy_attachment" "sftpgw_restricted" {
count = var.bucket_access == "restricted" ? 1 : 0
role = aws_iam_role.sftpgw_restricted[0].name
policy_arn = aws_iam_policy.sftpgw.arn
}
resource "aws_iam_role_policy_attachment" "sftpgw_open" {
count = var.bucket_access == "open" ? 1 : 0
role = aws_iam_role.sftpgw_open[0].name
policy_arn = aws_iam_policy.sftpgw.arn
}
resource "aws_iam_instance_profile" "sftpgw" {
name = "${var.stack_name}-instance-profile"
role = var.bucket_access == "restricted" ? aws_iam_role.sftpgw_restricted[0].name : aws_iam_role.sftpgw_open[0].name
}
# Launch Template
resource "aws_launch_template" "sftpgw" {
name = "${var.stack_name}-launch-template"
image_id = var.image_id != "" ? var.image_id : lookup(var.ami_map, var.aws_region)
instance_type = var.ec2_type
key_name = var.key_pair
vpc_security_group_ids = [aws_security_group.sftpgw.id]
depends_on = [aws_iam_instance_profile.sftpgw]
iam_instance_profile {
name = aws_iam_instance_profile.sftpgw.name
}
block_device_mappings {
device_name = "/dev/xvda"
ebs {
volume_size = var.disk_volume_size
volume_type = "gp2"
encrypted = true
delete_on_termination = true
}
}
user_data = base64encode(templatefile("user_data.yml", {
log_group_name = aws_cloudwatch_log_group.sftpgw.name
secret_id = aws_secretsmanager_secret.db_secret.arn
db_host = aws_db_instance.sftpgw.address
web_admin_username = var.web_admin_username
web_admin_password = var.web_admin_password
stack_name = var.stack_name
aws_region = var.aws_region
}))
tag_specifications {
resource_type = "instance"
tags = merge(local.common_tags, {
Name = "${var.stack_name}-instance"
})
}
tags = merge(local.common_tags, {
Name = "${var.stack_name}-launch-template"
})
}
# Auto Scaling Group
resource "aws_autoscaling_group" "sftpgw" {
name = "${var.stack_name}-asg"
vpc_zone_identifier = [aws_subnet.private_c.id, aws_subnet.private_d.id]
target_group_arns = [
aws_lb_target_group.sftp_22.arn,
aws_lb_target_group.ssh_2222.arn,
aws_lb_target_group.http_80.arn,
aws_lb_target_group.https_443.arn
]
health_check_type = "ELB"
health_check_grace_period = 300 # FIXED: Match CloudFormation default (5 minutes)
wait_for_capacity_timeout = "15m"
wait_for_elb_capacity = var.desired_capacity
min_size = 1
max_size = 10
desired_capacity = var.desired_capacity
launch_template {
id = aws_launch_template.sftpgw.id
version = "$Latest"
}
# Add instance refresh for better updates
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 50
}
}
tag {
key = "Name"
value = "${var.stack_name}-asg"
propagate_at_launch = false
}
dynamic "tag" {
for_each = local.common_tags
content {
key = tag.key
value = tag.value
propagate_at_launch = false
}
}
# FIXED: Match CloudFormation dependency structure
depends_on = [
aws_iam_role_policy_attachment.sftpgw_restricted,
aws_iam_role_policy_attachment.sftpgw_open
]
}
# Outputs
output "hostname" {
description = "Network Load Balancer DNS"
value = aws_lb.network.dns_name
}
output "cloudwatch_logs" {
description = "CloudWatch logs group"
value = aws_cloudwatch_log_group.sftpgw.name
}
output "database_endpoint" {
description = "RDS database endpoint"
value = aws_db_instance.sftpgw.endpoint
}
output "load_balancer_primary_ip" {
description = "Load balancer primary public IP"
value = aws_eip.lb.public_ip
}
terraform.tfvars
# ===========================
# REQUIRED CONFIGURATION
# ===========================
# Deployment name
stack_name = "sftpgw-tf-ha"
# AWS Region
aws_region = "us-east-1"
# EC2 Key Pair (must exist in the region)
key_pair = "sftpgw-ssh-key"
# Network Access (replace with your IP)
input_cidr = "3.222.237.17/32" # SSH/Web admin access
sftp_input_cidr = "0.0.0.0/0" # SFTP access (or restrict as needed)
# ===========================
# OPTIONAL CONFIGURATION
# ===========================
# Instance configuration
ec2_type = "t3.medium"
disk_volume_size = 32
desired_capacity = 2
# S3 bucket access
bucket_access = "open"
# Web admin credentials
web_admin_username = "WebAdmin"
web_admin_password = "MyPassword123!"
# Network configuration
vpc_ip_range = "192.168.1.0/24"
# Database configuration
db_class = "db.t3.micro"