IaC AWS

Terraform Module: AWS Transfer Family — managed SFTP servers with IAM-scoped S3 access

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

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 configlive/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 configlive/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

TerraformAWSTransfer FamilyModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading