What it actually is
Storage Gateway is a cache with a protocol front end. That is the whole product. You run a virtual appliance (a VM on your hypervisor, a hardware box, or an EC2 instance), it speaks a protocol your existing software already understands (NFS, SMB, iSCSI), and behind that protocol it keeps durable data in AWS (S3, Glacier, EBS snapshots). The local disk on the appliance is a cache, not the system of record.
That framing explains every decision you will make with it. Writes land on the local cache, get acknowledged to the client, and are copied to AWS asynchronously. Reads hit the cache first and fall through to AWS on a miss. The gateway exists to hide the distance between your on-premises workload and the cloud, so a backup tool that only knows how to write to a file share or a tape library can use S3 without anyone rewriting it.
Three gateway types are current. S3 File Gateway presents NFS and SMB and stores each file as an S3 object. Tape Gateway presents an iSCSI virtual tape library and stores tapes in S3, with archived tapes in S3 Glacier or Glacier Deep Archive. Volume Gateway presents iSCSI block volumes and stores point-in-time snapshots as EBS snapshots. A fourth type, Amazon FSx File Gateway, is closed to new customers as of October 28, 2024. Existing customers can keep using it, and AWS points everyone else to FSx for Windows File Server directly. If you find a tutorial that starts with FSx File Gateway, close the tab.
This article builds the S3 File Gateway, because it is the one most teams actually need and the one with the least ceremony. If you want the Glacier retrieval math that Tape Gateway inherits, the earlier issue on S3 Glacier covers it.
The model
An S3 File Gateway has four moving parts. The gateway is the appliance. A file share is an NFS or SMB export, bound to exactly one S3 bucket (or a prefix of it, or an S3 access point). The cache is local disk, shared by all file shares on that gateway, between 150 GiB and 64 TiB, evicted by least-recently-used. The IAM role is what the gateway assumes to read and write the bucket.
Objects and files map one to one. A file at /reports/q3.csv in the share becomes the S3 object reports/q3.csv, with POSIX metadata (mode, owner, timestamps) stored as object metadata. That is the feature that makes the data usable from both sides: your legacy app writes files, and Athena, Lambda, or a Glue job reads the same objects with the S3 API.
The sharp edge in that design is that the gateway is not a live view of the bucket. It keeps a cached inventory of what is in S3, and it only learns about changes made through itself. If someone puts an object directly into the bucket, the NFS client will not see it until the gateway refreshes that inventory with RefreshCache. The refresh is asynchronous, one at a time per gateway, and recursive refreshes on large prefixes cost real S3 API calls. This is the number one source of "the gateway is broken" tickets, and it is not broken. It is a cache.
What AWS operates: the control plane, the S3 durability, the managed endpoints. What you operate: the appliance, its host, its cache disks, its network path, its patching. That last item is not abstract. AWS is moving appliances from Amazon Linux 2 to Amazon Linux 2023 (S3 File Gateway 1.x to 2.x, Tape and Volume Gateway 2.x to 3.x). New deployments default to AL2023 since October 28, 2025, new AL2 activations were restricted from January 5, 2026, and AL2 gateways stopped receiving updates, patches and support on June 30, 2026. If you inherited a gateway, check its details tab or the deprecation date in DescribeGatewayInformation today.
When to use it, when not to
Use it when a workload cannot be changed and needs file or block semantics against cloud storage: a backup product that writes to NFS, a media pipeline that drops files on an SMB share, a tape-based process you are not ready to retire. The honest test is whether the application could call the S3 API instead. If it could, it should, and AWS's own FAQ says exactly that.
You are comparing | Choose Storage Gateway when | Choose the other when |
|---|---|---|
DataSync | Applications keep reading and writing the data continuously with low-latency local access | You are moving or replicating a dataset on a schedule, one time or recurring |
Transfer Family | Your clients are internal and speak NFS or SMB | Your clients are external partners who speak SFTP, FTPS, or FTP |
EFS or FSx in AWS | The clients live on premises and need a local cache | The clients live in AWS and need a real shared file system |
Direct S3 API or Mountpoint for S3 | The application needs POSIX file semantics it cannot give up | You control the application, or it only reads large objects sequentially |
The gateway is a poor fit for a few things. Do not use it as a general-purpose NAS replacement for latency-sensitive random writes, because every cache miss is a round trip to S3. Do not point two gateways at the same prefix and expect coherence, because neither knows what the other wrote. And do not choose it for a pure lift and shift to AWS where no on-premises consumer remains. That is an S3 problem or an FSx problem, not a gateway problem.
What it costs
The cost model has three layers, and the gateway fee is the smallest. First, the gateway itself. AWS's FAQ states S3 File Gateway writes are billed at a flat $0.01 per GB written to AWS, capped at $125 per gateway per month, with no charge for reading from AWS. The pricing page lists the first 100 GB written per account as free. Rates vary by region and gateway host, so confirm on the pricing page before you build a budget around my numbers.
Second, you pay for the host. On EC2 that is the instance plus the EBS cache volumes. On premises it is your hardware. Third, and this is the layer that surprises people, you pay S3 for storage and for the requests the gateway makes on your behalf: PUTs on upload, LISTs and GETs on cache refresh, and lifecycle transitions if you set them. Downloads from AWS to the gateway are billed as internet data transfer, uploads are free.
The one that bites: recursive RefreshCache on a prefix with millions of objects, run every few minutes on a schedule. That is a LIST storm. AWS's own guidance is to refresh only the directories that changed, and to schedule it at roughly 30 minutes, or 1 to 2 hours for large buckets, rather than triggering it from S3 events.
Following along in this article costs a few tens of cents. An m5.xlarge at roughly $0.19 per hour on demand in us-east-1, a 150 GiB gp3 cache volume at about $0.08 per GB-month, a small client instance, and a handful of S3 requests. If you finish inside two hours and clean up, plan for well under $1. Verify the instance price in your region before you start.
The limits that bite
The documented quotas for S3 File Gateway are specific and worth knowing before the design review, not after. A gateway supports up to 50 file shares. Each share maps to one bucket, and multiple shares can target the same bucket only if their prefixes do not overlap. The maximum file size is 5 TiB. Maximum path length is 1024 bytes and maximum file name length is 255 bytes, both UTF-8 encoded, for NFS and SMB alike.
Metadata caching is set by GatewayCapacity: Small is 5 million files, Medium 10 million, Large 20 million. The cache disk ranges from 150 GiB to 64 TiB. You can add cache disks later but you cannot shrink the cache, and you should add new disks rather than resize allocated ones.
The failure modes show up at the edges of those numbers. The cache is shared across shares, so one chatty share evicts everyone else's hot data. A file written at exactly the wrong size behaves differently per client: past 5 TiB, a piecewise writer gets truncated at 5 TiB, while a client writing all at once gets no file on Windows and a zero-byte file on Linux. And one cost-and-safety item for EC2 hosts: instance types with NVMe instance storage, such as i3, can lose cache data when the gateway stops or starts, so only stop an instance when the CachePercentDirty CloudWatch metric is 0.
Build it
We will build an S3 File Gateway on EC2, give it a 150 GiB cache, create an NFS share bound to a bucket, mount it from a client, and prove both directions: file to object, and object to file with the refresh gotcha.
Prerequisites
You need an AWS account, the AWS CLI v2, Python 3.10 or newer with boto3, and a default VPC with a public subnet in your region. You need an EC2 key pair for the client instance. Everything below uses us-east-1; change the region variable if you prefer another.
The IAM principal you run this with needs permissions for these actions: ec2:RunInstances, ec2:CreateSecurityGroup, ec2:AuthorizeSecurityGroupIngress, ec2:CreateVolume, ec2:AttachVolume, ec2:Describe*, ec2:TerminateInstances, ec2:DeleteVolume, ec2:DeleteSecurityGroup, ssm:GetParameter, s3:CreateBucket, s3:DeleteBucket, s3:PutObject, s3:GetObject, s3:ListBucket, s3:DeleteObject, iam:CreateRole, iam:PutRolePolicy, iam:PassRole, iam:DeleteRole, iam:DeleteRolePolicy, and storagegateway:* for this exercise. In a real account, scope that down. Note that the file share creation also requires AWS STS to be active in the region, which it is by default.
Step 1: bucket and role
The gateway needs an S3 bucket and an IAM role it can assume. The trust policy names the Storage Gateway service principal, and the permissions policy is the one AWS documents for direct bucket access.
import boto3, json, uuid
REGION = "us-east-1"
sts = boto3.client("sts", region_name=REGION)
s3 = boto3.client("s3", region_name=REGION)
iam = boto3.client("iam")
acct = sts.get_caller_identity()["Account"]
BUCKET = f"sgw-demo-{acct}-{uuid.uuid4().hex[:6]}"
ROLE = "SGWDemoFileShareRole"
s3.create_bucket(Bucket=BUCKET) # us-east-1 needs no LocationConstraint
trust = {"Version": "2012-10-17", "Statement": [{
"Effect": "Allow",
"Principal": {"Service": "storagegateway.amazonaws.com"},
"Action": "sts:AssumeRole"}]}
policy = {"Version": "2012-10-17", "Statement": [
{"Effect": "Allow", "Resource": f"arn:aws:s3:::{BUCKET}",
"Action": ["s3:GetAccelerateConfiguration", "s3:GetBucketLocation",
"s3:GetBucketVersioning", "s3:ListBucket",
"s3:ListBucketVersions", "s3:ListBucketMultipartUploads"]},
{"Effect": "Allow", "Resource": f"arn:aws:s3:::{BUCKET}/*",
"Action": ["s3:AbortMultipartUpload", "s3:DeleteObject",
"s3:DeleteObjectVersion", "s3:GetObject", "s3:GetObjectAcl",
"s3:GetObjectVersion", "s3:ListMultipartUploadParts",
"s3:PutObject", "s3:PutObjectAcl"]}]}
role_arn = iam.create_role(RoleName=ROLE,
AssumeRolePolicyDocument=json.dumps(trust))["Role"]["Arn"]
iam.put_role_policy(RoleName=ROLE, PolicyName="bucket-access",
PolicyDocument=json.dumps(policy))
print(BUCKET, role_arn)
If your bucket uses SSE-KMS, the role also needs kms:Encrypt, kms:Decrypt, kms:ReEncrypt*, kms:GenerateDataKey, and kms:DescribeKey on the key. We use the default SSE-S3 here, so we skip it.
Step 2: launch the gateway appliance
The AMI is published by AWS and discoverable through a public Systems Manager parameter, so you never hardcode an AMI ID. The documented starting instance type is m5.xlarge. We add a second EBS volume of 150 GiB for the cache, and open only what we need: port 80 for the one-time activation (restricted to your current IP), and 2049 plus 111 for NFS from inside the VPC.
import urllib.request
ec2 = boto3.client("ec2", region_name=REGION)
ssm = boto3.client("ssm", region_name=REGION)
ami = ssm.get_parameter(
Name="/aws/service/storagegateway/ami/FILE_S3/latest")["Parameter"]["Value"]
vpc = ec2.describe_vpcs(Filters=[{"Name": "isDefault", "Values": ["true"]}])["Vpcs"][0]
my_ip = urllib.request.urlopen("https://checkip.amazonaws.com").read().decode().strip()
sg = ec2.create_security_group(GroupName="sgw-demo", Description="sgw demo",
VpcId=vpc["VpcId"])["GroupId"]
ec2.authorize_security_group_ingress(GroupId=sg, IpPermissions=[
{"IpProtocol": "tcp", "FromPort": 80, "ToPort": 80,
"IpRanges": [{"CidrIp": f"{my_ip}/32"}]},
{"IpProtocol": "tcp", "FromPort": 2049, "ToPort": 2049,
"IpRanges": [{"CidrIp": vpc["CidrBlock"]}]},
{"IpProtocol": "tcp", "FromPort": 111, "ToPort": 111,
"IpRanges": [{"CidrIp": vpc["CidrBlock"]}]},
{"IpProtocol": "udp", "FromPort": 111, "ToPort": 111,
"IpRanges": [{"CidrIp": vpc["CidrBlock"]}]},
{"IpProtocol": "tcp", "FromPort": 20048, "ToPort": 20048,
"IpRanges": [{"CidrIp": vpc["CidrBlock"]}]}])
inst = ec2.run_instances(
ImageId=ami, InstanceType="m5.xlarge", MinCount=1, MaxCount=1,
NetworkInterfaces=[{"DeviceIndex": 0, "AssociatePublicIpAddress": True,
"Groups": [sg]}],
BlockDeviceMappings=[{"DeviceName": "/dev/sdb",
"Ebs": {"VolumeSize": 150, "VolumeType": "gp3",
"DeleteOnTermination": True}}],
TagSpecifications=[{"ResourceType": "instance",
"Tags": [{"Key": "Name", "Value": "sgw-demo"}]}],
)["Instances"][0]["InstanceId"]
ec2.get_waiter("instance_running").wait(InstanceIds=[inst])
d = ec2.describe_instances(InstanceIds=[inst])["Reservations"][0]["Instances"][0]
gw_pub, gw_priv = d["PublicIpAddress"], d["PrivateIpAddress"]
print(inst, gw_pub, gw_priv)
Two things to notice. The cache volume is declared at launch with DeleteOnTermination, which matters for cleanup. And the security group deliberately does not open NFS to the world. A gateway share is also protected by the client list on the share itself, but the network layer is your first line.
Step 3: activate
Activation is a handshake: you ask the gateway for a one-time key over HTTP on port 80, then hand that key to the ActivateGateway API. Keys expire after 30 minutes if unused. Wait a minute or two after the instance reports running, because the appliance needs to finish booting before it answers.
import time
sgw = boto3.client("storagegateway", region_name=REGION)
url = f"http://{gw_pub}/?activationRegion={REGION}&no_redirect"
for _ in range(30):
try:
key = urllib.request.urlopen(url, timeout=5).read().decode().strip()
break
except Exception:
time.sleep(10)
gw_arn = sgw.activate_gateway(
ActivationKey=key, GatewayName="sgw-demo",
GatewayTimezone="GMT", GatewayRegion=REGION,
GatewayType="FILE_S3")["GatewayARN"]
print(gw_arn)
The gateway type is fixed at activation and cannot be changed afterward. If you activate the wrong type, you delete and start over. Once activated, port 80 has done its job and you can close it.
Step 4: assign the cache disk
The 150 GiB volume is attached but unused until you tell the gateway to use it for cache. List the local disks and add the one that is not the root.
for _ in range(30):
disks = sgw.list_local_disks(GatewayARN=gw_arn)["Disks"]
if disks: break
time.sleep(10)
cache_ids = [x["DiskId"] for x in disks
if x.get("DiskAllocationType") in (None, "AVAILABLE")]
print(disks)
sgw.add_cache(GatewayARN=gw_arn, DiskIds=cache_ids)
If list_local_disks comes back empty, or cache_ids is empty, wait a minute and list again. The root volume is preconfigured by the AMI, and the cache volume is the extra disk you attached. Remember the rule: you can add cache later, you cannot shrink it.
The share binds the gateway to the bucket. The client list restricts which source addresses can mount it, and the squash setting controls how root on the client is treated. We allow only the VPC CIDR and give the share an explicit name, which becomes the mount path.
share = sgw.create_nfs_file_share(
ClientToken=uuid.uuid4().hex, GatewayARN=gw_arn, Role=role_arn,
LocationARN=f"arn:aws:s3:::{BUCKET}", FileShareName="demo-share",
ClientList=[vpc["CidrBlock"]], DefaultStorageClass="S3_STANDARD",
Squash="NoSquash",
NFSFileShareDefaults={"FileMode": "0666", "DirectoryMode": "0777",
"GroupId": 65534, "OwnerId": 65534},
)["FileShareARN"]
for _ in range(30):
st = sgw.describe_nfs_file_shares(FileShareARNList=[share])["NFSFileShareInfoList"][0]["FileShareStatus"]
if st == "AVAILABLE": break
time.sleep(10)
print(share, st)
We use NoSquash so that our demo client, running as root via sudo, can write; production shares usually want RootSquash and real owner mappings. DefaultStorageClass accepts S3_STANDARD, S3_INTELLIGENT_TIERING, S3_STANDARD_IA, and S3_ONEZONE_IA. If you know the data goes cold, set it here rather than relying on a lifecycle rule to move it later.
Step 6: mount from a client
The gateway only serves clients inside the allowed CIDR, so launch a small client in the same VPC and subnet, SSH in with your key pair, and mount. Any Linux host in the VPC works.
sudo yum install -y nfs-utils # Amazon Linux
sudo mkdir -p /mnt/gw
sudo mount -t nfs -o nolock,hard GATEWAY_PRIVATE_IP:/demo-share /mnt/gw
df -h /mnt/gw
The nolock and hard options are the ones AWS recommends in its mount instructions for Linux clients. The mount path is the file share name, which is why we set one.
Verify it works
Verification has two directions, and you need both to believe the model.
File to object. Write a file through the mount, then ask the gateway to tell you when everything written has reached S3, and list the bucket.
echo "hello from the hybrid side" | sudo tee /mnt/gw/hello.txt
sgw.notify_when_uploaded(FileShareARN=share) # emits a CloudWatch event when done
for _ in range(30):
objs = s3.list_objects_v2(Bucket=BUCKET).get("Contents", [])
if objs: break
time.sleep(5)
print([o["Key"] for o in objs])
You should see ['hello.txt']. The write returned to the client the instant it hit the cache, and the object appeared in S3 some seconds later. That gap is the asynchronous upload, and it is the thing to remember when someone asks whether data is "safe" at the moment a write returns: it is durable on the gateway's cache disk, not yet in S3.
Object to file. Now do the thing that surprises people.
s3.put_object(Bucket=BUCKET, Key="direct.txt", Body=b"written straight to S3")
On the client, run ls /mnt/gw. You will see hello.txt but not direct.txt. The gateway has no idea the object exists. Trigger the refresh and list again.
sgw.refresh_cache(FileShareARN=share, FolderList=["/"], Recursive=False)
After a few seconds, ls /mnt/gw shows both files. RefreshCache is asynchronous, returns before it completes, and only one runs at a time, so give it a moment and subscribe to the refresh-complete notification if you automate it. In production, schedule the refresh on the directories that receive out-of-band writes, not on the whole share.
When it breaks
Activation times out or returns an error. The most common cause is the instance still booting, or your source IP changing so the port 80 rule no longer matches. Recheck the security group and retry; keys expire after 30 minutes. If the region passed to the activation URL is missing or invalid, it silently defaults to us-east-1, so a typo can activate you in the wrong place.
The share stays in CREATING or the mount hangs. Check the client list contains the client's address, the NFS ports (2049, 111, 20048) are open from the client, and the cache disk was actually added. A gateway with no cache cannot serve a share.
direct.txt never appears. You did not refresh the cache. If it still does not appear after a refresh, check that the file share's prefix actually covers the key you wrote.
Files appear on the client but never in S3. The role cannot write the bucket, or the bucket uses KMS and the role lacks the KMS permissions, or the bucket policy denies the role. The CloudWatch metrics and the gateway's health notifications will show the upload failures.
The gateway shows a deprecation warning. It is running the Amazon Linux 2 image. Migrate to a new AL2023 appliance following the migration guide, and do not run old and new side by side.
Cleanup
Delete in this order so nothing is left billing. Deleting a file share or a gateway does not delete the objects in your bucket, so you must empty and remove the bucket yourself.
sgw.delete_file_share(FileShareARN=share, ForceDelete=True)
time.sleep(30)
sgw.delete_gateway(GatewayARN=gw_arn)
ec2.terminate_instances(InstanceIds=[inst])
ec2.get_waiter("instance_terminated").wait(InstanceIds=[inst])
ec2.delete_security_group(GroupId=sg)
for o in s3.list_objects_v2(Bucket=BUCKET).get("Contents", []):
s3.delete_object(Bucket=BUCKET, Key=o["Key"])
s3.delete_bucket(Bucket=BUCKET)
iam.delete_role_policy(RoleName=ROLE, PolicyName="bucket-access")
iam.delete_role(RoleName=ROLE)
Also terminate the client instance you launched in step 6. Then confirm: aws ec2 describe-volumes --filters Name=status,Values=available should show no leftover cache volumes, aws storagegateway list-gateways should be empty, and your bill for the next day should show nothing for this exercise beyond the cents already spent.
Where to take it next
Easiest: set DefaultStorageClass to S3_INTELLIGENT_TIERING on a second share and compare the storage bill after a month. Medium: add an SMB share with Active Directory, which is what a Windows-heavy shop will actually need, and look at the cache report feature added in March 2025 to see which files are waiting to upload. Hardest: build the production shape, with the gateway in a private subnet, a VPC endpoint for Storage Gateway, a Lambda on a schedule that refreshes only the directories that changed, and CloudWatch alarms on CachePercentDirty and cache hit rate.
The question to leave with: if your application could call the S3 API tomorrow, would you still run an appliance you have to patch? The gateway is the right answer when the answer is no.

