Deploy SFTP Gateway on ECS Fargate with Terraform
TLDR - Quick Summary
What: Deploy SFTP Gateway on Amazon ECS Fargate across multiple Availability Zones, with RDS PostgreSQL, using Terraform
Resources: VPC, NAT gateways, NLB (SFTP), ALB (web admin), ECS Fargate service, RDS PostgreSQL Multi-AZ, S3, Secrets Manager, CloudWatch Logs
Run: Create 2 files (
sftpgw-fargate.tf,terraform.tfvars), thenterraform init && terraform apply
Overview
This guide deploys SFTP Gateway version 3.9.0 as containers on Amazon ECS with the Fargate launch type, so there are no EC2 instances to patch or scale. The Terraform template builds a new VPC and runs everything across at least two Availability Zones:
- Two SFTP Gateway tasks, one in each Availability Zone
- RDS PostgreSQL with a Multi-AZ standby
- A Network Load Balancer for SFTP and an Application Load Balancer for the web admin UI, both spanning the zones
If a task or a whole Availability Zone fails, the load balancers send new connections to the healthy task, and ECS starts a replacement.
Note: This template is a reference architecture intended as a starting point. Customize it to meet your own network, security and compliance requirements. If you need help, our Premier Support team is available.
No EFS: this configuration does not support Local File System folders
This template deliberately uses no shared file system. SFTP Gateway keeps all of its shared state in the database and in S3:
| State | Where it lives |
|---|---|
| Users, folders, cloud connections, settings | RDS PostgreSQL |
| SSH host keys | RDS PostgreSQL. The first task to start generates them, and every other task loads the same keys, so clients see one consistent host key behind the load balancer. |
| Scheduled jobs | RDS PostgreSQL (clustered, so each job runs on one task at a time) |
| Uploaded files | Amazon S3 |
| Logs | Amazon CloudWatch Logs |
| License | AWS Secrets Manager |
The one feature that needs a shared disk is Local File System (LFS) folders, which store files on the server itself instead of in cloud storage. This configuration assumes LFS is not used: every folder maps to a cloud connection such as S3. If you need LFS, this is not the right template.
Architecture
SFTP clients Admins
│ :22 │ :80 / :443
┌─────────────────────┼──────────────────────────┼────────────────────┐
│ VPC ▼ ▼ │
│ Public ┌────────────────┐ ┌────────────────┐ │
│ subnets │ Network LB │ │ Application LB │ │
│ (2 AZs) └───────┬────────┘ └───────┬────────┘ │
│ │ :2244 │ :8090 │
│ Private ┌───────▼─────────────────────────▼────────┐ │
│ subnets │ ECS Fargate service (1 task per AZ) │ │
│ (2 AZs) │ ┌───────────────────────────────────┐ │ │
│ │ │ Task │ │ │
│ │ │ backend (SFTP :2244, API :8080) │ │ │
│ │ │ admin-ui (nginx :8090) ─► localhost:8080 │
│ │ └───────────────────────────────────┘ │ │
│ └───────┬──────────────────┬───────────────┘ │
│ │ :5432 │ S3 gateway endpoint │
│ ┌───────▼────────┐ ▼ │
│ │ RDS PostgreSQL │ Amazon S3 │
│ │ Multi-AZ │ │
│ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Each task runs two containers: the SFTP Gateway backend and the web admin UI. The admin UI reaches the backend over localhost inside its own task, so no service discovery is needed. Because the backend's login tokens are signed with a secret shared by every task, it doesn't matter which task the ALB sends an admin request to.
What gets created
| Resource | Details |
|---|---|
| VPC | 2 public and 2 private subnets across 2 Availability Zones (3 with az_count = 3) |
| NAT gateways | One per Availability Zone, so outbound traffic survives a zone outage |
| S3 gateway endpoint | S3 traffic stays inside AWS and skips the NAT gateways |
| Network Load Balancer | Port 22 to container port 2244; preserves client IPs; cross-zone enabled |
| Application Load Balancer | Web admin UI; HTTPS when you provide a certificate |
| ECS cluster and service | Fargate, desired_count = 2, spread across zones, rolling deployments with automatic rollback |
| RDS PostgreSQL 18 | Multi-AZ, encrypted, automated backups, storage autoscaling |
| Secrets Manager | Database password, OAuth client secret, JWT signing secret, and the license (optional) |
| S3 bucket | Default cloud connection, encrypted, public access blocked |
| IAM roles | Task role limited to the S3 bucket(s) and the log group; execution role for image pulls and secrets |
| CloudWatch log group | Container output plus SFTP Gateway's application, sftp-audit and license-audit streams |
Cost
With the defaults, the AWS infrastructure costs roughly $370 per month in us-east-1 at on-demand prices, before data transfer. This does not include the SFTP Gateway license.
| Item | Monthly |
|---|---|
| 2 Fargate tasks, 2 vCPU / 4 GB each | $144 |
RDS PostgreSQL db.t4g.medium, Multi-AZ, 20 GB | $99 |
| 2 NAT gateways | $66 |
| Network Load Balancer + Application Load Balancer | $33 |
| 6 public IPv4 addresses (NAT gateways and load balancers) | $22 |
| Secrets Manager, CloudWatch Logs, Container Insights | ~$5 |
Most of the cost comes from running two of everything so the stack survives an Availability Zone outage. To bring it down:
- Smaller tasks:
task_cpu = 1024andtask_memory = 3072saves about $65. Test this under your real workload first. See Memory Settings. - Smaller database:
db_instance_class = "db.t4g.small"saves about $47. - One NAT gateway:
single_nat_gateway = truesaves about $37. Outbound internet access (image pulls, licensing) then depends on one zone. S3 access doesn't, because it goes through the VPC endpoint. - Savings Plans: Fargate is covered by AWS Compute Savings Plans, and RDS by reserved instances.
With the first three changes the stack costs about $220 per month and is still multi-AZ.
Prerequisites
- Terraform 1.5 or later
- AWS credentials with permission to create the resources above (for example,
AdministratorAccessin a sandbox account) - Your public IP address, to restrict access to the web admin UI. To find it, run
curl -s https://checkip.amazonaws.com. - Optional: an ACM certificate, or a Route 53 hosted zone, for HTTPS on the admin UI
Tip: AWS CloudShell already has credentials and can run Terraform. See Using the AWS CloudShell for installing Terraform there.
Running the template
Create a new directory and save the two files from Terraform file contents below into it:
sftpgw-fargate.tfandterraform.tfvars.Edit
terraform.tfvars. At a minimum setname,regionandadmin_allowed_cidrs.admin_allowed_cidrstakes CIDR blocks, so add/32to a single IP address, for example["203.0.113.10/32"].If this is a test stack you plan to delete, also uncomment
deletion_protection = falsenow. That saves an extra step when you delete it later.Deploy:
terraform init
terraform apply
Type yes when prompted. The apply takes about 15 minutes, mostly waiting for RDS Multi-AZ.
- When it finishes, Terraform prints the outputs, including:
admin_url = "http://sftpgw-admin-1234567890.us-east-1.elb.amazonaws.com"
sftp_endpoint = "sftpgw-sftp-0123456789abcdef.elb.us-east-1.amazonaws.com"
log_group = "/sftpgw/sftpgw"
s3_bucket = "sftpgw-123456789012-us-east-1"
- The tasks need a minute or two after the apply to start and pass health checks. Check that both are running:
aws ecs describe-services --cluster sftpgw --services sftpgw \
--query 'services[0].[runningCount,desiredCount]' --output text
2 2 means both tasks are up. If you see 0 2, the tasks are still starting: wait a minute and run the command again.
Note: The commands in this article use the cluster and service name
sftpgw, the defaultname. If you set a differentnameinterraform.tfvars, use that instead.
Setting up SFTP Gateway
Create the admin account
Open admin_url in your browser and create your Web Admin account.
Important: Until the first admin account exists, anyone who can reach the admin UI can create it. That's why
admin_allowed_cidrsis required. Create the account right after deploying.
Start a free trial or add a license
After you sign in, the admin UI asks you to start a free trial or enter a license. Without one, SFTP Gateway won't accept SFTP connections.
To use a license key you already have, set license in terraform.tfvars and run terraform apply again. The key is stored in Secrets Manager and the tasks are replaced to pick it up. Tasks read the secret only when they start, so if you later change the key, restart them with:
aws ecs update-service --cluster sftpgw --service sftpgw --force-new-deployment
To buy a license after your trial, see Purchasing a License.
Cloud connection
The template creates a default cloud connection pointing at the S3 bucket in the s3_bucket output. It authenticates with the ECS task's IAM role, so you don't need AWS access keys. Run Test Connection to confirm it shows three green checkmarks.
To use other buckets, add their ARNs to additional_s3_bucket_arns and run terraform apply, then create the cloud connections in the admin UI.
Create an SFTP user and connect
Create a user on the Users tab, then connect with any SFTP client:
- Host: the
sftp_endpointoutput - Port: 22
- Username / password: the user you created
sftp <username>@<sftp_endpoint>
How it works
High availability
- Tasks: ECS keeps
desired_counttasks running and spreads them across Availability Zones. If a task fails its health check, the load balancers stop sending it traffic and ECS replaces it. Existing SFTP sessions on a failed task drop and clients reconnect to the other task. - Host keys: every task serves the same SSH host keys from the database, so a client that reconnects to a different task gets no host key warning.
- Database: RDS Multi-AZ fails over to its standby automatically, usually within 1 to 2 minutes.
- Load balancers and NAT: both load balancers span every zone, and each zone has its own NAT gateway.
Security
| Security group | Allows in |
|---|---|
| SFTP NLB | Port 22 from sftp_allowed_cidrs |
| Admin ALB | Ports 80/443 from admin_allowed_cidrs |
| Tasks | Port 2244 from the NLB, port 8090 from the ALB |
| RDS | Port 5432 from the tasks |
The tasks and the database sit in private subnets with no public IPs. The NLB preserves client IPs, so SFTP Gateway's audit log and per-user IP allowlists see the real client address.
Idle SFTP sessions
A Network Load Balancer drops idle TCP connections after 350 seconds by default, which disconnects SFTP clients that sit idle. This template raises the listener's idle timeout to one hour (nlb_idle_timeout_seconds).
Viewing logs
Everything goes to the CloudWatch log group in the log_group output:
| Log stream | Contents |
|---|---|
application | SFTP Gateway application log, from every task |
sftp-audit | SFTP connections, logins and file operations, from every task |
license-audit | License events |
ecs/backend/<task-id> | Backend container output for one task |
ecs/admin-ui/<task-id> | Web admin UI (nginx) output for one task |
# Follow everything
aws logs tail /sftpgw/sftpgw --follow
# Only the SFTP audit trail
aws logs tail /sftpgw/sftpgw --follow --log-stream-names sftp-audit
Operations
Upgrading SFTP Gateway
Change sftpgw_version in terraform.tfvars and run terraform apply. ECS starts new tasks, waits for them to pass health checks, then drains the old ones, which keep existing SFTP sessions for up to 5 minutes. If the new tasks fail to start, ECS rolls back automatically.
Scaling
Change desired_count, task_cpu or task_memory and run terraform apply. Keep desired_count at least equal to az_count so every zone has a task. See Memory Settings for sizing guidance.
Getting a shell in a task
ECS Exec is enabled. With the Session Manager plugin installed:
TASK=$(aws ecs list-tasks --cluster sftpgw --service-name sftpgw --query 'taskArns[0]' --output text)
aws ecs execute-command --cluster sftpgw --task "$TASK" --container backend --interactive --command sh
Deleting the stack
Deletion protection is on by default for the database and load balancers. To remove everything, set deletion_protection = false in terraform.tfvars (uncomment the line at the bottom) and run terraform apply. Skip that step if you already set it before deploying. Then run:
terraform destroy
The destroy takes about 12 minutes. With deletion protection off, the S3 bucket is emptied and deleted, and no final database snapshot is kept.
Afterwards, AWS keeps a few records that cost nothing and need no action: the ECS cluster and task definition stay visible as INACTIVE, and the database's automated snapshot takes a few minutes to disappear.
Variables reference
| Variable | Default | Description |
|---|---|---|
name | sftpgw | Name prefix for every resource |
region | us-east-1 | AWS region |
admin_allowed_cidrs | (required) | CIDRs allowed to reach the web admin UI |
sftp_allowed_cidrs | ["0.0.0.0/0"] | CIDRs allowed to connect over SFTP |
az_count | 2 | Availability Zones to span (2 or 3) |
single_nat_gateway | false | One NAT gateway instead of one per zone |
vpc_cidr | 10.20.0.0/16 | VPC address range |
acm_certificate_arn | null | Existing certificate for HTTPS on the admin UI |
route53_zone_id | null | Hosted zone for issuing a certificate and DNS records |
admin_domain_name | null | Admin UI hostname (with route53_zone_id) |
sftp_domain_name | null | SFTP hostname (with route53_zone_id) |
sftpgw_version | 3.9.0 | SFTP Gateway image tag |
desired_count | 2 | Number of tasks |
task_cpu / task_memory | 2048 / 4096 | Fargate task size |
license | null | License key; leave unset to start a trial from the UI |
s3_bucket_name | null | Default bucket name (generated if unset) |
create_s3_bucket | true | Set false to use an existing bucket named in s3_bucket_name |
additional_s3_bucket_arns | [] | Other buckets the tasks may use |
nlb_idle_timeout_seconds | 3600 | Idle timeout for SFTP connections |
log_retention_days | 90 | CloudWatch Logs retention |
db_instance_class | db.t4g.medium | RDS instance class |
db_engine_version | 18 | PostgreSQL major version |
db_allocated_storage | 20 | RDS storage in GiB (autoscales to 5x) |
db_multi_az | true | RDS Multi-AZ standby |
db_backup_retention_days | 7 | RDS backup retention |
deletion_protection | true | Protect the database and load balancers from deletion |
Terraform file contents
sftpgw-fargate.tf
# SFTP Gateway on Amazon ECS Fargate - Terraform Template
#
# Multi-AZ: Fargate tasks in 2+ Availability Zones, RDS PostgreSQL Multi-AZ,
# a Network Load Balancer for SFTP and an Application Load Balancer for the
# web admin UI. No EFS: all shared state lives in RDS and S3, so this
# template does not support Local File System (LFS) folders.
# =============================================================================
# versions
# =============================================================================
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = merge({ Project = var.name }, var.tags)
}
}
# =============================================================================
# variables
# =============================================================================
variable "name" {
description = "Name prefix for every resource. Keep it short: it is used in load balancer and target group names (32-char limit)."
type = string
default = "sftpgw"
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,15}$", var.name))
error_message = "name must be 2-16 lowercase letters, digits or hyphens, starting with a letter."
}
}
variable "region" {
description = "AWS region."
type = string
default = "us-east-1"
}
variable "tags" {
description = "Extra tags applied to every resource."
type = map(string)
default = {
aws-apn-id = "pc:2oyqn6klrhfnk"
}
}
# ---------------------------------------------------------------------------
# Network
# ---------------------------------------------------------------------------
variable "vpc_cidr" {
description = "CIDR block for the new VPC."
type = string
default = "10.20.0.0/16"
}
variable "az_count" {
description = "Number of Availability Zones to span. Tasks, NAT gateways, subnets and load balancer nodes are spread across these."
type = number
default = 2
validation {
condition = var.az_count >= 2 && var.az_count <= 3
error_message = "az_count must be 2 or 3 (multi-AZ is the point of this configuration)."
}
}
variable "single_nat_gateway" {
description = "Use one NAT gateway instead of one per AZ. Cheaper, but an AZ outage in the NAT's zone cuts outbound traffic (S3 is unaffected: it uses a gateway endpoint)."
type = bool
default = false
}
variable "sftp_allowed_cidrs" {
description = "Client CIDRs allowed to connect to SFTP on port 22."
type = list(string)
default = ["0.0.0.0/0"]
}
variable "admin_allowed_cidrs" {
description = "CIDRs allowed to reach the web admin UI. Lock this down: until the first admin account is created, whoever reaches the UI first can create it."
type = list(string)
}
# ---------------------------------------------------------------------------
# Admin UI TLS (pick one, or neither for HTTP-only testing)
# ---------------------------------------------------------------------------
variable "acm_certificate_arn" {
description = "Existing ACM certificate ARN for the admin UI. Leave null to have Terraform issue one (needs admin_domain_name + route53_zone_id), or to run HTTP-only."
type = string
default = null
}
variable "admin_domain_name" {
description = "Hostname for the admin UI, e.g. sftp-admin.example.com. With route53_zone_id set, Terraform issues a DNS-validated certificate and creates the alias record."
type = string
default = null
}
variable "route53_zone_id" {
description = "Route 53 hosted zone ID for admin_domain_name."
type = string
default = null
}
variable "sftp_domain_name" {
description = "Optional hostname for the SFTP endpoint, e.g. sftp.example.com. Needs route53_zone_id."
type = string
default = null
}
# ---------------------------------------------------------------------------
# SFTP Gateway
# ---------------------------------------------------------------------------
variable "sftpgw_version" {
description = "Image tag for thorntech/sftpgateway-backend and thorntech/sftpgateway-admin-ui. Pin it; do not use latest."
type = string
default = "3.9.0"
}
variable "desired_count" {
description = "Number of SFTP Gateway tasks. Keep this at least az_count so every AZ has a task."
type = number
default = 2
}
variable "task_cpu" {
description = "Fargate task CPU units (1024 = 1 vCPU)."
type = number
default = 2048
}
variable "task_memory" {
description = "Fargate task memory in MiB. The admin UI container gets 256 MiB of it; the backend gets the rest."
type = number
default = 4096
}
variable "license" {
description = "SFTP Gateway license key. Leave null to run unlicensed (limited to 5 SFTP users)."
type = string
default = null
sensitive = true
}
variable "s3_bucket_name" {
description = "Name of the S3 bucket for the default cloud connection. Leave null to create a bucket named <name>-<account>-<region>."
type = string
default = null
}
variable "create_s3_bucket" {
description = "Create the default S3 bucket. Set false to use an existing bucket named in s3_bucket_name."
type = bool
default = true
}
variable "additional_s3_bucket_arns" {
description = "Other bucket ARNs the tasks may use for additional cloud connections."
type = list(string)
default = []
}
variable "nlb_idle_timeout_seconds" {
description = "TCP idle timeout on the SFTP listener. The NLB default (350s) drops idle SFTP sessions; clients that sit idle longer need this raised."
type = number
default = 3600
}
variable "log_retention_days" {
description = "CloudWatch Logs retention for application and audit logs."
type = number
default = 90
}
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
variable "db_instance_class" {
description = "RDS instance class."
type = string
default = "db.t4g.medium"
}
variable "db_engine_version" {
description = "PostgreSQL major version."
type = string
default = "18"
}
variable "db_allocated_storage" {
description = "Initial RDS storage in GiB. Storage autoscaling is enabled up to 5x this."
type = number
default = 20
}
variable "db_multi_az" {
description = "Run RDS Multi-AZ (synchronous standby in a second AZ)."
type = bool
default = true
}
variable "db_backup_retention_days" {
description = "Automated backup retention in days."
type = number
default = 7
}
variable "deletion_protection" {
description = "Protect the RDS instance and load balancers from deletion. Set false for test stacks you intend to destroy."
type = bool
default = true
}
# =============================================================================
# network
# =============================================================================
data "aws_availability_zones" "available" {
state = "available"
}
locals {
azs = slice(data.aws_availability_zones.available.names, 0, var.az_count)
# /20s: public subnets take the first az_count blocks, private the next az_count.
public_subnet_cidrs = [for i in range(var.az_count) : cidrsubnet(var.vpc_cidr, 4, i)]
private_subnet_cidrs = [for i in range(var.az_count) : cidrsubnet(var.vpc_cidr, 4, i + 8)]
nat_count = var.single_nat_gateway ? 1 : var.az_count
}
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = var.name }
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = { Name = var.name }
}
# Public subnets hold the load balancers and NAT gateways only.
resource "aws_subnet" "public" {
count = var.az_count
vpc_id = aws_vpc.this.id
cidr_block = local.public_subnet_cidrs[count.index]
availability_zone = local.azs[count.index]
tags = { Name = "${var.name}-public-${local.azs[count.index]}" }
}
# Private subnets hold the Fargate tasks and RDS.
resource "aws_subnet" "private" {
count = var.az_count
vpc_id = aws_vpc.this.id
cidr_block = local.private_subnet_cidrs[count.index]
availability_zone = local.azs[count.index]
tags = { Name = "${var.name}-private-${local.azs[count.index]}" }
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
tags = { Name = "${var.name}-public" }
}
resource "aws_route_table_association" "public" {
count = var.az_count
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_eip" "nat" {
count = local.nat_count
domain = "vpc"
tags = { Name = "${var.name}-nat-${local.azs[count.index]}" }
}
resource "aws_nat_gateway" "this" {
count = local.nat_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = { Name = "${var.name}-${local.azs[count.index]}" }
depends_on = [aws_internet_gateway.this]
}
# One private route table per AZ, each pointing at its own AZ's NAT gateway
# (or all at the single NAT when single_nat_gateway = true).
resource "aws_route_table" "private" {
count = var.az_count
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[var.single_nat_gateway ? 0 : count.index].id
}
tags = { Name = "${var.name}-private-${local.azs[count.index]}" }
}
resource "aws_route_table_association" "private" {
count = var.az_count
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
# S3 traffic from the tasks skips the NAT gateways: free, and not tied to any one AZ.
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.this.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = aws_route_table.private[*].id
tags = { Name = "${var.name}-s3" }
}
# =============================================================================
# security_groups
# =============================================================================
locals {
sftp_container_port = 2244 # SFTP inside the backend image (non-root, so not 22)
api_container_port = 8080 # backend REST API, reached only over localhost by the admin UI
admin_container_port = 8090 # admin UI nginx; moved off 8080 because it shares the task's network namespace with the backend
}
# --- SFTP network load balancer ---------------------------------------------
resource "aws_security_group" "nlb" {
name = "${var.name}-sftp-nlb"
description = "SFTP clients to the network load balancer"
vpc_id = aws_vpc.this.id
tags = { Name = "${var.name}-sftp-nlb" }
}
resource "aws_vpc_security_group_ingress_rule" "nlb_sftp" {
for_each = toset(var.sftp_allowed_cidrs)
security_group_id = aws_security_group.nlb.id
description = "SFTP"
cidr_ipv4 = each.value
ip_protocol = "tcp"
from_port = 22
to_port = 22
}
resource "aws_vpc_security_group_egress_rule" "nlb_to_tasks" {
security_group_id = aws_security_group.nlb.id
description = "SFTP and health checks to tasks"
referenced_security_group_id = aws_security_group.tasks.id
ip_protocol = "tcp"
from_port = local.sftp_container_port
to_port = local.sftp_container_port
}
# --- Admin UI application load balancer ---------------------------------------
resource "aws_security_group" "alb" {
name = "${var.name}-admin-alb"
description = "Admins to the web admin UI load balancer"
vpc_id = aws_vpc.this.id
tags = { Name = "${var.name}-admin-alb" }
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
for_each = toset(var.admin_allowed_cidrs)
security_group_id = aws_security_group.alb.id
description = "HTTP (redirects to HTTPS when a certificate is configured)"
cidr_ipv4 = each.value
ip_protocol = "tcp"
from_port = 80
to_port = 80
}
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
for_each = local.admin_tls ? toset(var.admin_allowed_cidrs) : toset([])
security_group_id = aws_security_group.alb.id
description = "HTTPS"
cidr_ipv4 = each.value
ip_protocol = "tcp"
from_port = 443
to_port = 443
}
resource "aws_vpc_security_group_egress_rule" "alb_to_tasks" {
security_group_id = aws_security_group.alb.id
description = "Admin UI and health checks to tasks"
referenced_security_group_id = aws_security_group.tasks.id
ip_protocol = "tcp"
from_port = local.admin_container_port
to_port = local.admin_container_port
}
# --- Fargate tasks ------------------------------------------------------------
resource "aws_security_group" "tasks" {
name = "${var.name}-tasks"
description = "SFTP Gateway Fargate tasks"
vpc_id = aws_vpc.this.id
tags = { Name = "${var.name}-tasks" }
}
# The NLB preserves client IPs (so per-user IP allowlists and audit logs see the
# real client), but because the NLB has a security group, targets can still
# authorize it by reference instead of by client CIDR.
resource "aws_vpc_security_group_ingress_rule" "tasks_sftp" {
security_group_id = aws_security_group.tasks.id
description = "SFTP from the NLB"
referenced_security_group_id = aws_security_group.nlb.id
ip_protocol = "tcp"
from_port = local.sftp_container_port
to_port = local.sftp_container_port
}
resource "aws_vpc_security_group_ingress_rule" "tasks_admin" {
security_group_id = aws_security_group.tasks.id
description = "Admin UI from the ALB"
referenced_security_group_id = aws_security_group.alb.id
ip_protocol = "tcp"
from_port = local.admin_container_port
to_port = local.admin_container_port
}
# Outbound: Docker Hub image pulls, S3, Secrets Manager, CloudWatch Logs, RDS,
# and the license/telemetry endpoint.
resource "aws_vpc_security_group_egress_rule" "tasks_all" {
security_group_id = aws_security_group.tasks.id
description = "All outbound"
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
# --- RDS ------------------------------------------------------------------------
resource "aws_security_group" "db" {
name = "${var.name}-db"
description = "PostgreSQL for SFTP Gateway"
vpc_id = aws_vpc.this.id
tags = { Name = "${var.name}-db" }
}
resource "aws_vpc_security_group_ingress_rule" "db_from_tasks" {
security_group_id = aws_security_group.db.id
description = "PostgreSQL from tasks"
referenced_security_group_id = aws_security_group.tasks.id
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
}
# =============================================================================
# database
# =============================================================================
resource "aws_db_subnet_group" "this" {
name = var.name
subnet_ids = aws_subnet.private[*].id
}
# A Terraform-managed password rather than RDS-managed (manage_master_user_password):
# RDS rotates its managed secret every 7 days, and running tasks only read the
# secret at start, so a rotation would break their new database connections.
resource "random_password" "db" {
length = 32
special = false
}
resource "aws_db_instance" "this" {
identifier = var.name
engine = "postgres"
engine_version = var.db_engine_version
instance_class = var.db_instance_class
db_name = "sftpgw"
username = "sftpgw"
password = random_password.db.result
allocated_storage = var.db_allocated_storage
max_allocated_storage = var.db_allocated_storage * 5
storage_type = "gp3"
storage_encrypted = true
multi_az = var.db_multi_az
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [aws_security_group.db.id]
publicly_accessible = false
backup_retention_period = var.db_backup_retention_days
auto_minor_version_upgrade = true
deletion_protection = var.deletion_protection
skip_final_snapshot = !var.deletion_protection
final_snapshot_identifier = var.deletion_protection ? "${var.name}-final" : null
copy_tags_to_snapshot = true
}
# =============================================================================
# secrets
# =============================================================================
# Every task must share the same OAuth client secret and JWT signing secret, so
# a token issued by one task is accepted by the others behind the load balancer.
resource "random_password" "client_secret" {
length = 40
special = false
}
resource "random_password" "jwt_secret" {
length = 64
special = false
}
resource "aws_secretsmanager_secret" "app" {
name = "${var.name}/app"
description = "SFTP Gateway database password, OAuth client secret and JWT secret"
recovery_window_in_days = 0
}
resource "aws_secretsmanager_secret_version" "app" {
secret_id = aws_secretsmanager_secret.app.id
secret_string = jsonencode({
db_password = random_password.db.result
client_secret = random_password.client_secret.result
jwt_secret = random_password.jwt_secret.result
})
}
resource "aws_secretsmanager_secret" "license" {
count = var.license == null ? 0 : 1
name = "${var.name}/license"
description = "SFTP Gateway license key"
recovery_window_in_days = 0
}
resource "aws_secretsmanager_secret_version" "license" {
count = var.license == null ? 0 : 1
secret_id = aws_secretsmanager_secret.license[0].id
secret_string = var.license
}
# =============================================================================
# s3
# =============================================================================
data "aws_caller_identity" "current" {}
locals {
bucket_name = coalesce(var.s3_bucket_name, "${var.name}-${data.aws_caller_identity.current.account_id}-${var.region}")
bucket_arn = "arn:aws:s3:::${local.bucket_name}"
}
resource "aws_s3_bucket" "default" {
count = var.create_s3_bucket ? 1 : 0
bucket = local.bucket_name
# Lets `terraform destroy` remove a bucket that still holds files. Only
# applies when deletion protection is off, i.e. for test stacks.
force_destroy = !var.deletion_protection
}
resource "aws_s3_bucket_public_access_block" "default" {
count = var.create_s3_bucket ? 1 : 0
bucket = aws_s3_bucket.default[0].id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "default" {
count = var.create_s3_bucket ? 1 : 0
bucket = aws_s3_bucket.default[0].id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# =============================================================================
# iam
# =============================================================================
data "aws_iam_policy_document" "ecs_tasks_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
locals {
bucket_arns = concat([local.bucket_arn], var.additional_s3_bucket_arns)
secret_arns = concat(
[aws_secretsmanager_secret.app.arn],
aws_secretsmanager_secret.license[*].arn,
)
}
# --- Execution role: used by ECS itself to pull images, inject secrets, ship stdout logs.
resource "aws_iam_role" "execution" {
name = "${var.name}-execution"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}
resource "aws_iam_role_policy_attachment" "execution" {
role = aws_iam_role.execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
data "aws_iam_policy_document" "execution_secrets" {
statement {
actions = ["secretsmanager:GetSecretValue"]
resources = local.secret_arns
}
}
resource "aws_iam_role_policy" "execution_secrets" {
name = "secrets"
role = aws_iam_role.execution.id
policy = data.aws_iam_policy_document.execution_secrets.json
}
# --- Task role: what SFTP Gateway itself can do.
resource "aws_iam_role" "task" {
name = "${var.name}-task"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}
data "aws_iam_policy_document" "task" {
statement {
sid = "Buckets"
actions = ["s3:GetBucketLocation", "s3:ListBucket", "s3:ListBucketMultipartUploads"]
resources = local.bucket_arns
}
statement {
sid = "Objects"
actions = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts",
]
resources = [for arn in local.bucket_arns : "${arn}/*"]
}
# SFTP Gateway's own CloudWatch appender writes the application, sftp-audit
# and license-audit streams; the admin UI's diagnostics screen reads them back.
statement {
sid = "Logs"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams",
"logs:GetLogEvents",
"logs:FilterLogEvents",
]
resources = ["${aws_cloudwatch_log_group.app.arn}:*"]
}
# ECS Exec (aws ecs execute-command) for troubleshooting.
statement {
sid = "EcsExec"
actions = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
]
resources = ["*"]
}
}
resource "aws_iam_role_policy" "task" {
name = "sftpgateway"
role = aws_iam_role.task.id
policy = data.aws_iam_policy_document.task.json
}
# =============================================================================
# load_balancers
# =============================================================================
# --- SFTP: network load balancer, port 22 -> task port 2244 -------------------
resource "aws_lb" "sftp" {
name = "${var.name}-sftp"
load_balancer_type = "network"
internal = false
subnets = aws_subnet.public[*].id
security_groups = [aws_security_group.nlb.id]
enable_cross_zone_load_balancing = true
enable_deletion_protection = var.deletion_protection
}
resource "aws_lb_target_group" "sftp" {
name = "${var.name}-sftp"
port = local.sftp_container_port
protocol = "TCP"
target_type = "ip"
vpc_id = aws_vpc.this.id
preserve_client_ip = true
# Give in-flight transfers time to finish when a task is replaced.
deregistration_delay = 300
health_check {
protocol = "TCP"
port = "traffic-port"
interval = 10
healthy_threshold = 2
unhealthy_threshold = 2
}
}
resource "aws_lb_listener" "sftp" {
load_balancer_arn = aws_lb.sftp.arn
port = 22
protocol = "TCP"
tcp_idle_timeout_seconds = var.nlb_idle_timeout_seconds
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.sftp.arn
}
}
# --- Admin UI: application load balancer ----------------------------------------
resource "aws_lb" "admin" {
name = "${var.name}-admin"
load_balancer_type = "application"
internal = false
subnets = aws_subnet.public[*].id
security_groups = [aws_security_group.alb.id]
enable_deletion_protection = var.deletion_protection
drop_invalid_header_fields = true
}
resource "aws_lb_target_group" "admin" {
name = "${var.name}-admin"
port = local.admin_container_port
protocol = "HTTP"
target_type = "ip"
vpc_id = aws_vpc.this.id
deregistration_delay = 30
health_check {
path = "/index.html"
matcher = "200"
interval = 15
healthy_threshold = 2
unhealthy_threshold = 3
}
}
resource "aws_lb_listener" "admin_http" {
load_balancer_arn = aws_lb.admin.arn
port = 80
protocol = "HTTP"
default_action {
type = local.admin_tls ? "redirect" : "forward"
target_group_arn = local.admin_tls ? null : aws_lb_target_group.admin.arn
dynamic "redirect" {
for_each = local.admin_tls ? [1] : []
content {
protocol = "HTTPS"
port = "443"
status_code = "HTTP_301"
}
}
}
}
resource "aws_lb_listener" "admin_https" {
count = local.admin_tls ? 1 : 0
load_balancer_arn = aws_lb.admin.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = local.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.admin.arn
}
}
# =============================================================================
# dns
# =============================================================================
locals {
issue_certificate = var.acm_certificate_arn == null && var.admin_domain_name != null && var.route53_zone_id != null
certificate_arn = local.issue_certificate ? aws_acm_certificate_validation.admin[0].certificate_arn : var.acm_certificate_arn
admin_tls = var.acm_certificate_arn != null || local.issue_certificate
}
resource "aws_acm_certificate" "admin" {
count = local.issue_certificate ? 1 : 0
domain_name = var.admin_domain_name
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "admin_validation" {
for_each = local.issue_certificate ? {
for o in aws_acm_certificate.admin[0].domain_validation_options : o.domain_name => o
} : {}
zone_id = var.route53_zone_id
name = each.value.resource_record_name
type = each.value.resource_record_type
records = [each.value.resource_record_value]
ttl = 60
allow_overwrite = true
}
resource "aws_acm_certificate_validation" "admin" {
count = local.issue_certificate ? 1 : 0
certificate_arn = aws_acm_certificate.admin[0].arn
validation_record_fqdns = [for r in aws_route53_record.admin_validation : r.fqdn]
}
resource "aws_route53_record" "admin" {
count = var.admin_domain_name != null && var.route53_zone_id != null ? 1 : 0
zone_id = var.route53_zone_id
name = var.admin_domain_name
type = "A"
alias {
name = aws_lb.admin.dns_name
zone_id = aws_lb.admin.zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "sftp" {
count = var.sftp_domain_name != null && var.route53_zone_id != null ? 1 : 0
zone_id = var.route53_zone_id
name = var.sftp_domain_name
type = "A"
alias {
name = aws_lb.sftp.dns_name
zone_id = aws_lb.sftp.zone_id
evaluate_target_health = false
}
}
# =============================================================================
# ecs
# =============================================================================
resource "aws_cloudwatch_log_group" "app" {
name = "/sftpgw/${var.name}"
retention_in_days = var.log_retention_days
}
# Container Insights writes to this group. Left to ECS, it would be created
# outside Terraform and survive `terraform destroy`.
resource "aws_cloudwatch_log_group" "container_insights" {
name = "/aws/ecs/containerinsights/${var.name}/performance"
retention_in_days = 1
}
resource "aws_ecs_cluster" "this" {
name = var.name
setting {
name = "containerInsights"
value = "enabled"
}
depends_on = [aws_cloudwatch_log_group.container_insights]
}
locals {
client_id = "${var.name}-client"
app_arn = aws_secretsmanager_secret.app.arn
backend = {
name = "backend"
image = "thorntech/sftpgateway-backend:${var.sftpgw_version}"
essential = true
memory = var.task_memory - 256
portMappings = [
{ containerPort = local.sftp_container_port, protocol = "tcp" },
{ containerPort = local.api_container_port, protocol = "tcp" },
]
environment = [
{ name = "SPRING_DATASOURCE_URL", value = "jdbc:postgresql://${aws_db_instance.this.address}:${aws_db_instance.this.port}/${aws_db_instance.this.db_name}" },
{ name = "SPRING_DATASOURCE_USERNAME", value = aws_db_instance.this.username },
{ name = "SECURITY_CLIENT_ID", value = local.client_id },
{ name = "SERVER_PORT", value = tostring(local.api_container_port) },
{ name = "SFTP_PORT", value = tostring(local.sftp_container_port) },
{ name = "AWS_REGION", value = var.region },
{ name = "FEATURES_INSTANCE_CLOUD_PROVIDER", value = "aws" },
# Default cloud connection, created on first boot only.
{ name = "FEATURES_FIRST_CONNECTION_CLOUD_PROVIDER", value = "aws" },
{ name = "FEATURES_FIRST_CONNECTION_NAME", value = "Default S3" },
{ name = "FEATURES_FIRST_CONNECTION_REGION", value = var.region },
{ name = "FEATURES_FIRST_CONNECTION_BASE_PREFIX", value = "s3://${local.bucket_name}" },
# Send the application, sftp-audit and license-audit logs to CloudWatch.
# Without this the audit log only goes to a file on the task's ephemeral
# disk, and is lost whenever the task is replaced.
{ name = "FEATURES_CLOUDLOGGING_AWS_LOGGROUPNAME", value = aws_cloudwatch_log_group.app.name },
]
secrets = concat(
[
{ name = "SPRING_DATASOURCE_PASSWORD", valueFrom = "${local.app_arn}:db_password::" },
{ name = "SECURITY_CLIENT_SECRET", valueFrom = "${local.app_arn}:client_secret::" },
{ name = "SECURITY_JWT_SECRET", valueFrom = "${local.app_arn}:jwt_secret::" },
],
[for arn in aws_secretsmanager_secret.license[*].arn : { name = "LICENSE", valueFrom = arn }],
)
healthCheck = {
command = ["CMD-SHELL", "wget -q --spider http://localhost:${local.api_container_port}/actuator/health || exit 1"]
interval = 30
timeout = 10
retries = 3
startPeriod = 180
}
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.app.name
awslogs-region = var.region
awslogs-stream-prefix = "ecs"
}
}
}
admin_ui = {
name = "admin-ui"
image = "thorntech/sftpgateway-admin-ui:${var.sftpgw_version}"
essential = true
memory = 256
portMappings = [
{ containerPort = local.admin_container_port, protocol = "tcp" },
]
environment = [
# Same task, same network namespace: the UI proxies /backend/ to its own
# task's backend, so no service discovery is needed.
{ name = "BACKEND_URL", value = "http://localhost:${local.api_container_port}/" },
{ name = "NGINX_PORT", value = tostring(local.admin_container_port) },
{ name = "SECURITY_CLIENT_ID", value = local.client_id },
{ name = "CLOUD_PROVIDER", value = "aws" },
# TLS ends at the ALB; nginx serves plain HTTP.
{ name = "EXTERNAL_TLS_TERMINATION", value = "true" },
{ name = "WEBSITE_BUNDLE_CRT", value = "" },
{ name = "WEBSITE_KEY", value = "" },
]
secrets = [
{ name = "SECURITY_CLIENT_SECRET", valueFrom = "${local.app_arn}:client_secret::" },
]
dependsOn = [{ containerName = "backend", condition = "START" }]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.app.name
awslogs-region = var.region
awslogs-stream-prefix = "ecs"
}
}
}
}
resource "aws_ecs_task_definition" "this" {
family = var.name
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = var.task_cpu
memory = var.task_memory
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "X86_64"
}
# No volumes: every piece of shared state lives in RDS or S3.
container_definitions = jsonencode([local.backend, local.admin_ui])
}
resource "aws_ecs_service" "this" {
name = var.name
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.this.arn
desired_count = var.desired_count
launch_type = "FARGATE"
network_configuration {
subnets = aws_subnet.private[*].id
security_groups = [aws_security_group.tasks.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.sftp.arn
container_name = "backend"
container_port = local.sftp_container_port
}
load_balancer {
target_group_arn = aws_lb_target_group.admin.arn
container_name = "admin-ui"
container_port = local.admin_container_port
}
# First boot runs the database migrations before SFTP comes up.
health_check_grace_period_seconds = 300
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
availability_zone_rebalancing = "ENABLED"
deployment_circuit_breaker {
enable = true
rollback = true
}
enable_execute_command = true
propagate_tags = "SERVICE"
depends_on = [
aws_lb_listener.sftp,
aws_lb_listener.admin_http,
aws_iam_role_policy.execution_secrets,
aws_secretsmanager_secret_version.app,
]
}
# =============================================================================
# outputs
# =============================================================================
output "admin_url" {
description = "Web admin UI. Open it and create the first admin account."
value = local.admin_tls ? (
"https://${coalesce(var.admin_domain_name, aws_lb.admin.dns_name)}"
) : (
"http://${aws_lb.admin.dns_name}"
)
}
output "sftp_endpoint" {
description = "SFTP host name (port 22)."
value = coalesce(var.sftp_domain_name, aws_lb.sftp.dns_name)
}
output "sftp_nlb_dns_name" {
description = "NLB DNS name, for a CNAME in DNS you manage elsewhere."
value = aws_lb.sftp.dns_name
}
output "s3_bucket" {
description = "Bucket behind the default cloud connection."
value = local.bucket_name
}
output "log_group" {
description = "CloudWatch log group: application, sftp-audit and license-audit streams, plus ecs/* container output."
value = aws_cloudwatch_log_group.app.name
}
output "ecs_cluster" {
value = aws_ecs_cluster.this.name
}
output "ecs_service" {
value = aws_ecs_service.this.name
}
output "db_endpoint" {
value = aws_db_instance.this.address
}
output "client_id" {
description = "OAuth client ID the admin UI uses; needed to call the admin API directly."
value = local.client_id
}
output "app_secret_arn" {
description = "Secrets Manager secret holding db_password, client_secret and jwt_secret."
value = aws_secretsmanager_secret.app.arn
}
terraform.tfvars
# ===========================
# REQUIRED
# ===========================
# Name prefix for every resource (2-16 lowercase letters, digits or hyphens)
name = "sftpgw"
region = "us-east-1"
# Who may reach the web admin UI. Use your own public IP.
admin_allowed_cidrs = ["203.0.113.10/32"]
# ===========================
# OPTIONAL
# ===========================
# Who may connect over SFTP (default: anyone)
# sftp_allowed_cidrs = ["0.0.0.0/0"]
# HTTPS for the admin UI. Either use an existing ACM certificate...
# acm_certificate_arn = "arn:aws:acm:us-east-1:123456789012:certificate/..."
# ...or let Terraform issue one in a Route 53 zone you own:
# route53_zone_id = "Z0123456789ABCDEFGHIJ"
# admin_domain_name = "sftp-admin.example.com"
# sftp_domain_name = "sftp.example.com"
# License key. Leave unset to start a free trial from the admin UI.
# license = "paste license key here"
# Set to false for a test stack you plan to destroy.
# deletion_protection = false