Quick take — A production-ready Terraform module for AWS Transfer Family on hashicorp/aws ~> 5.0: SFTP servers with service-managed users, per-user scope-down IAM policies, S3 home directories, structured logging, and security-policy pinning. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "transfer_family" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-transfer-family?ref=v1.0.0"
environment = "..." # Environment short name used in default resource naming …
s3_bucket_name = "..." # Existing S3 bucket backing user home directories; valid…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS Transfer Family is a fully managed file-transfer service that puts SFTP, FTPS, and FTP endpoints in front of Amazon S3 (or EFS) without you running a single file-server EC2 instance. The common use case is the one nobody enjoys building by hand: a partner, a bank, or a legacy ERP system only speaks SFTP, drops a file every night, and your platform needs to land that file in S3 — encrypted, audited, and access-scoped so partner A can never see partner B’s data. The aws_transfer_server resource provisions the endpoint, but a working SFTP setup is never just the server: it is the server plus a per-user IAM role, a per-user scope-down policy that pins each login to its own S3 prefix, the home-directory mapping, the public-key registration, and a CloudWatch logging role — five moving parts that have to agree on names, ARNs, and conditions or the connection silently fails authorization.
This module wraps aws_transfer_server together with the sub-resources almost every SFTP deployment needs and ships them least-privilege by default: SFTP-only protocol, a strong pinned security_policy_name, structured (JSON) CloudWatch logging, and service-managed users whose access is locked to a single S3 home directory via a scope-down session policy. You declare users as a map and get back a fully wired, auditable endpoint — you do not hand-write five interdependent resources per partner.
When to use it
- A partner or external system speaks only SFTP/FTPS and you need to land their files in S3 without operating a self-managed file server, patching it, or scaling it.
- You onboard many users/partners and want each one scoped to their own S3 prefix by an enforced policy, so a misconfigured client can never list or read another tenant’s data.
- You need transfer activity logged and auditable (who connected, what they uploaded) for compliance, and want logging wired to CloudWatch as code rather than clicked on per server.
- You want the TLS/SSH security policy and protocol set pinned in version control so a plan review shows exactly which cipher suite and protocols the endpoint accepts.
Reach for additional composition (or a different design) when you need a custom Lambda/API Gateway identity provider (e.g. authenticating against Active Directory or Okta), EFS-backed home directories, a VPC-internal endpoint with Elastic IPs and security groups, or AS2 message processing — those are intentionally outside this module’s public-endpoint, S3-backed, service-managed baseline.
Module structure
terraform-module-aws-transfer-family/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# main.tf
data "aws_partition" "current" {}
data "aws_region" "current" {}
locals {
server_tag_name = coalesce(var.server_name, "transfer-${var.environment}")
# S3 bucket ARN the users are scoped into.
bucket_arn = "arn:${data.aws_partition.current.partition}:s3:::${var.s3_bucket_name}"
}
# IAM role assumed by the Transfer service to push connection/transfer logs.
data "aws_iam_policy_document" "logging_assume" {
count = var.enable_logging ? 1 : 0
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["transfer.amazonaws.com"]
}
}
}
resource "aws_iam_role" "logging" {
count = var.enable_logging ? 1 : 0
name = "${local.server_tag_name}-logging"
assume_role_policy = data.aws_iam_policy_document.logging_assume[0].json
tags = var.tags
}
resource "aws_iam_role_policy_attachment" "logging" {
count = var.enable_logging ? 1 : 0
role = aws_iam_role.logging[0].name
policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AWSTransferLoggingAccess"
}
# The SFTP server itself (public endpoint, S3-backed, service-managed identity).
resource "aws_transfer_server" "this" {
identity_provider_type = "SERVICE_MANAGED"
endpoint_type = "PUBLIC"
protocols = var.protocols
domain = "S3"
security_policy_name = var.security_policy_name
# Structured logging requires a destination and a role; JSON gives parseable logs.
structured_log_destinations = var.enable_logging ? [
"${aws_cloudwatch_log_group.this[0].arn}:*"
] : null
logging_role = var.enable_logging ? aws_iam_role.logging[0].arn : null
tags = merge(var.tags, {
Name = local.server_tag_name
ManagedBy = "terraform"
})
}
resource "aws_cloudwatch_log_group" "this" {
count = var.enable_logging ? 1 : 0
name = "/aws/transfer/${local.server_tag_name}"
retention_in_days = var.log_retention_days
tags = var.tags
}
# Per-user IAM role the server assumes to access S3 on that user's behalf.
data "aws_iam_policy_document" "user_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["transfer.amazonaws.com"]
}
}
}
resource "aws_iam_role" "user" {
for_each = var.users
name = "${local.server_tag_name}-user-${each.key}"
assume_role_policy = data.aws_iam_policy_document.user_assume.json
tags = var.tags
}
# Broad S3 permissions on the role; the per-user scope-down policy (below)
# narrows the effective session to one home prefix.
data "aws_iam_policy_document" "user_s3" {
for_each = var.users
statement {
sid = "ListBucket"
effect = "Allow"
actions = ["s3:ListBucket", "s3:GetBucketLocation"]
resources = [local.bucket_arn]
}
statement {
sid = "ObjectAccess"
effect = "Allow"
actions = [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:GetObjectVersion",
"s3:GetObjectACL",
"s3:PutObjectACL",
]
resources = ["${local.bucket_arn}/*"]
}
}
resource "aws_iam_role_policy" "user_s3" {
for_each = var.users
name = "s3-access"
role = aws_iam_role.user[each.key].id
policy = data.aws_iam_policy_document.user_s3[each.key].json
}
# Scope-down session policy: pins the user to ${transfer:HomeDirectory}.
# ${transfer:UserName} is resolved by Transfer Family at connect time.
data "aws_iam_policy_document" "scope_down" {
for_each = var.users
statement {
sid = "AllowListingHome"
effect = "Allow"
actions = ["s3:ListBucket"]
resources = [local.bucket_arn]
condition {
test = "StringLike"
variable = "s3:prefix"
values = ["${each.key}/*", "${each.key}"]
}
}
statement {
sid = "AllowObjectsInHome"
effect = "Allow"
actions = [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:GetObjectVersion",
]
resources = ["${local.bucket_arn}/${each.key}/*"]
}
}
resource "aws_transfer_user" "this" {
for_each = var.users
server_id = aws_transfer_server.this.id
user_name = each.key
role = aws_iam_role.user[each.key].arn
home_directory_type = "LOGICAL"
policy = data.aws_iam_policy_document.scope_down[each.key].json
# LOGICAL mapping presents "/" to the client but stores under <bucket>/<user>/.
home_directory_mappings {
entry = "/"
target = "/${var.s3_bucket_name}/${each.key}"
}
tags = merge(var.tags, { Name = each.key })
}
# Register each public key against its user (supports key rotation as a list).
resource "aws_transfer_ssh_key" "this" {
for_each = {
for pair in flatten([
for uname, u in var.users : [
for idx, key in u.ssh_public_keys : {
composite = "${uname}.${idx}"
user_name = uname
body = key
}
]
]) : pair.composite => pair
}
server_id = aws_transfer_server.this.id
user_name = aws_transfer_user.this[each.value.user_name].user_name
body = each.value.body
}
# variables.tf
variable "environment" {
description = "Environment short name used in default resource naming (e.g. prod, stage)."
type = string
}
variable "server_name" {
description = "Optional explicit name/tag for the Transfer server. Defaults to transfer-<environment>."
type = string
default = null
}
variable "s3_bucket_name" {
description = "Name of the existing S3 bucket that backs user home directories."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.s3_bucket_name))
error_message = "s3_bucket_name must be a valid, lowercase, DNS-compliant S3 bucket name."
}
}
variable "protocols" {
description = "File-transfer protocols the endpoint accepts. SFTP only is the secure default."
type = list(string)
default = ["SFTP"]
validation {
condition = alltrue([
for p in var.protocols : contains(["SFTP", "FTPS", "FTP"], p)
]) && length(var.protocols) > 0
error_message = "protocols must be a non-empty subset of [\"SFTP\", \"FTPS\", \"FTP\"]."
}
}
variable "security_policy_name" {
description = "TLS/SSH security policy pinned on the server. Use a recent dated policy, not the legacy default."
type = string
default = "TransferSecurityPolicy-2024-01"
validation {
condition = can(regex("^TransferSecurityPolicy-", var.security_policy_name))
error_message = "security_policy_name must be a TransferSecurityPolicy-* value."
}
}
variable "enable_logging" {
description = "Create a CloudWatch log group + logging role and stream structured (JSON) transfer logs."
type = bool
default = true
}
variable "log_retention_days" {
description = "CloudWatch Logs retention for the transfer log group."
type = number
default = 90
validation {
condition = contains(
[1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653],
var.log_retention_days
)
error_message = "log_retention_days must be a value supported by CloudWatch Logs."
}
}
variable "users" {
description = "Map of SFTP usernames to their public keys. Each user is scoped to <bucket>/<username>/."
type = map(object({
ssh_public_keys = list(string)
}))
default = {}
validation {
condition = alltrue([
for name, u in var.users : can(regex("^[a-zA-Z0-9_.@-]{3,100}$", name))
])
error_message = "Each username must be 3-100 chars of [A-Za-z0-9_.@-]."
}
validation {
condition = alltrue([
for name, u in var.users : length(u.ssh_public_keys) > 0
])
error_message = "Each user must have at least one SSH public key."
}
}
variable "tags" {
description = "Tags applied to all created resources."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "The Transfer server ID (s-xxxxxxxx)."
value = aws_transfer_server.this.id
}
output "arn" {
description = "The ARN of the Transfer server."
value = aws_transfer_server.this.arn
}
output "endpoint" {
description = "Server hostname clients connect to (<server-id>.server.transfer.<region>.amazonaws.com)."
value = "${aws_transfer_server.this.id}.server.transfer.${data.aws_region.current.name}.amazonaws.com"
}
output "host_key_fingerprint" {
description = "SHA-256 fingerprint of the server host key; publish to partners for known_hosts pinning."
value = aws_transfer_server.this.host_key_fingerprint
}
output "user_names" {
description = "List of provisioned SFTP usernames."
value = keys(aws_transfer_user.this)
}
output "logging_role_arn" {
description = "ARN of the CloudWatch logging role (null when logging is disabled)."
value = try(aws_iam_role.logging[0].arn, null)
}
output "log_group_name" {
description = "CloudWatch log group receiving transfer logs (null when logging is disabled)."
value = try(aws_cloudwatch_log_group.this[0].name, null)
}
How to use it
# A prod SFTP endpoint with two partners, each scoped to its own S3 prefix.
module "transfer_family" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-transfer-family?ref=v1.0.0"
environment = "prod"
s3_bucket_name = aws_s3_bucket.partner_inbound.id
protocols = ["SFTP"]
# Pin a recent security policy so accepted ciphers are reviewed in plan.
security_policy_name = "TransferSecurityPolicy-2024-01"
enable_logging = true
log_retention_days = 365
users = {
"acme-bank" = {
ssh_public_keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc... acme-bank@partner",
]
}
"globex-erp" = {
ssh_public_keys = [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQAB... globex-erp@partner",
]
}
}
tags = {
Environment = "prod"
Owner = "integration-team"
CostCenter = "b2b-edi"
}
}
# Downstream: a Route 53 alias-style CNAME so partners use a stable hostname.
resource "aws_route53_record" "sftp" {
zone_id = aws_route53_zone.corp.zone_id
name = "sftp.kloudvin.io"
type = "CNAME"
ttl = 300
records = [module.transfer_family.endpoint]
}
# Downstream: alarm on connection failures using the module's log group.
resource "aws_cloudwatch_log_metric_filter" "auth_failures" {
name = "sftp-auth-failures"
log_group_name = module.transfer_family.log_group_name
pattern = "{ $.activity-type = \"AUTH_FAILURE\" }"
metric_transformation {
name = "SftpAuthFailures"
namespace = "KloudVin/Transfer"
value = "1"
}
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/transfer_family/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-transfer-family?ref=v1.0.0"
}
inputs = {
environment = "..."
s3_bucket_name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/transfer_family && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
environment |
string |
— | Yes | Environment short name used in default resource naming (e.g. prod). |
server_name |
string |
null |
No | Explicit server name/tag. Defaults to transfer-<environment>. |
s3_bucket_name |
string |
— | Yes | Existing S3 bucket backing user home directories; validated as DNS-compliant. |
protocols |
list(string) |
["SFTP"] |
No | Subset of SFTP/FTPS/FTP. SFTP-only is the secure default. |
security_policy_name |
string |
"TransferSecurityPolicy-2024-01" |
No | Pinned TLS/SSH security policy; must be a TransferSecurityPolicy-* value. |
enable_logging |
bool |
true |
No | Create a logging role + CloudWatch log group and stream structured logs. |
log_retention_days |
number |
90 |
No | Retention for the transfer log group; must be a CloudWatch-supported value. |
users |
map(object) |
{} |
No | Username → { ssh_public_keys }. Each user is scoped to <bucket>/<username>/. |
tags |
map(string) |
{} |
No | Tags applied to all created resources. |
Outputs
| Name | Description |
|---|---|
id |
The Transfer server ID (s-xxxxxxxx). |
arn |
The ARN of the Transfer server. |
endpoint |
Hostname clients connect to (<id>.server.transfer.<region>.amazonaws.com). |
host_key_fingerprint |
SHA-256 host-key fingerprint to publish for partner known_hosts pinning. |
user_names |
List of provisioned SFTP usernames. |
logging_role_arn |
ARN of the CloudWatch logging role (null when logging is disabled). |
log_group_name |
CloudWatch log group receiving transfer logs (null when logging is disabled). |
Enterprise scenario
A retailer runs nightly B2B EDI exchange with a dozen suppliers, each of whom only supports SFTP. The integration team consumes this module once per environment, pointing it at a versioned, KMS-encrypted partner-inbound S3 bucket and declaring each supplier as a user with their published Ed25519 key. Because every user’s scope-down policy pins them to <bucket>/<supplier>/, supplier A physically cannot list or fetch supplier B’s purchase orders even though they share one endpoint, and the structured CloudWatch logs feed a metric filter that pages the on-call engineer the moment a supplier’s automated key starts failing authentication.
Best practices
- Stay SFTP-only and pin a dated security policy. Leave
protocols = ["SFTP"]and setsecurity_policy_nameto a recentTransferSecurityPolicy-YYYY-MMvalue (not the legacy default) so weak ciphers and SSH-RSA/SHA-1 are rejected and the accepted set is visible in every plan. - Enforce per-user isolation with scope-down policies, not trust. The module pins each user to
<bucket>/<username>/via a session policy and${transfer:UserName}-style prefixing — never widen the per-user role to the whole bucket, or one client misconfiguration exposes every tenant. - Encrypt the backing bucket and keep it private. Back the server with an SSE-KMS, fully public-access-blocked, versioned S3 bucket; Transfer Family writes through the user role, so versioning plus a deny-insecure-transport bucket policy gives you recoverability and TLS enforcement end to end.
- Mind the endpoint cost. A
PUBLICSFTP endpoint bills per hour it exists plus per GB transferred, so consolidate partners onto one server with many users rather than one server per partner, and destroy non-prod endpoints when idle. - Publish the host-key fingerprint and rotate user keys as a list. Hand partners the
host_key_fingerprintoutput forknown_hostspinning, and becausessh_public_keysis a list you can add a new partner key before removing the old one for zero-downtime rotation. - Front the endpoint with a stable CNAME and alarm on auth failures. Map a Route 53 record to the
endpointoutput so partners never hardcode thes-xxxxhostname, and wire a metric filter on thelog_group_nameto catch credential or key problems before a nightly batch silently fails.