This repository demonstrates secure, internet-isolated access to Amazon S3 from a private EC2 instance using a Gateway VPC Endpoint. It highlights best practices around access control, IAM roles, security groups, and restricted egress — all without relying on NAT Gateways or public IPs. The setup focuses on automation and fine-grained security.
- Does not consume ENIs, simplifying network interface management
- Enables route-based traffic control via route tables
- More scalable and reliable for high-throughput S3 operations
- No additional cost for Gateway Endpoints (S3 and DynamoDB) compared to the per-hour and per-GB charges of Interface Endpoints
- Full infrastructure setup via Terraform
- S3 object upload, download, verification, and logging done automatically
- Bastion host auto-configured using
file
andremote-exec
provisioners - IAM role and S3 policy created and attached to private EC2 without manual steps
- Key pair generation and placement handled in provisioning
- Principle of least privilege applied throughout
- Instance Role: EC2 instances are associated with a tightly-scoped IAM role, granting only the necessary permissions for interacting with S3 (
GetObject
,PutObject
,DeleteObject
). - Security Groups:
- Security groups are used to control inbound and outbound traffic.
- SSH access to the Bastion host is restricted to a specific IP (your local IP), ensuring only authorized users can access it.
- The private EC2 instance allows SSH only from the Bastion host’s security group, enforcing a strict access path.
- Outbound access from the private EC2 is restricted to only Amazon S3 using a Gateway VPC endpoint.
- Bucket Policy:
- The S3 bucket policy is defined to ensure only authorized IAM roles and instances can access the objects.
- The policy allows
GetObject
,PutObject
, andDeleteObject
actions, restricted to specific resources (the designated S3 bucket). - Public access to the bucket is blocked, ensuring that data cannot be accessed by external sources.
Initialize Terraform and apply the configuration to create all necessary infrastructure components automatically.
terraform init && terraform apply -auto-approve
Confirm that the infrastructure is properly set up by reviewing the following key configurations:
Perform connectivity and functionality testing by accessing the instances and validating S3 operations:
Find the SSH commands from outputs by running following command:
terraform output
- SSH into the Bastion host from your local machine.
- From the Bastion host, SSH into the private EC2 instance.
- Check the files in
/tmp
— you should seefile.txt
uploaded anddownloaded.txt
retrieved from S3. - View the content of
downloaded.txt
to verify the download succeeded. - Delete the file from the S3 bucket using the AWS CLI.
Clean up all provisioned infrastructure by running the destroy command:
terraform destroy -auto-approve
aws_iam_role_policy_attachment.attach: Destroying... [id=secure-app-ec2-s3-role-20250604164907712900000001]
aws_route_table_association.public: Destroying... [id=rtbassoc-0ace4ac671c713893]
aws_s3_bucket_policy.allow_vpce_only: Destroying... [id=secure-app-0gtl41ovm2mo-bucket]
aws_route_table_association.private: Destroying... [id=rtbassoc-005f5dbd9dd769878]
aws_instance.bastion: Destroying... [id=i-0e9e19333e83eafb9]
aws_instance.private_instance: Destroying... [id=i-0800699d2f140d0f6]
aws_s3_bucket_policy.allow_vpce_only: Destruction complete after 2s
aws_iam_role_policy_attachment.attach: Destruction complete after 3s
aws_iam_policy.s3_access: Destroying... [id=arn:aws:iam::policy/secure-app-s3-access-policy]
aws_route_table_association.private: Destruction complete after 3s
aws_iam_policy.s3_access: Destruction complete after 0s
aws_route_table_association.public: Destruction complete after 3s
aws_route_table.public: Destroying... [id=rtb-03052248b91be0165]
aws_route_table.public: Destruction complete after 2s
aws_internet_gateway.igw: Destroying... [id=igw-08921a9f18796bd7a]
aws_instance.private_instance: Still destroying... [id=i-0800699d2f140d0f6, 00m11s elapsed]
aws_instance.bastion: Still destroying... [id=i-0e9e19333e83eafb9, 00m11s elapsed]
aws_internet_gateway.igw: Still destroying... [id=igw-08921a9f18796bd7a, 00m10s elapsed]
aws_instance.bastion: Still destroying... [id=i-0e9e19333e83eafb9, 00m21s elapsed]
aws_instance.private_instance: Still destroying... [id=i-0800699d2f140d0f6, 00m21s elapsed]
aws_internet_gateway.igw: Still destroying... [id=igw-08921a9f18796bd7a, 00m20s elapsed]
aws_instance.private_instance: Destruction complete after 25s
aws_iam_instance_profile.ec2_s3_profile: Destroying... [id=secure-app-ec2-instance-profile]
aws_subnet.private: Destroying... [id=subnet-0e968859aa633dff0]
aws_security_group.private_sg: Destroying... [id=sg-0ee10a647c7395a2b]
aws_s3_bucket.storage: Destroying... [id=secure-app-0gtl41ovm2mo-bucket]
aws_iam_instance_profile.ec2_s3_profile: Destruction complete after 1s
aws_iam_role.ec2_s3_access: Destroying... [id=secure-app-ec2-s3-role]
aws_subnet.private: Destruction complete after 2s
aws_s3_bucket.storage: Destruction complete after 2s
random_string.str: Destroying... [id=0gtl41ovm2mo]
random_string.str: Destruction complete after 0s
aws_iam_role.ec2_s3_access: Destruction complete after 1s
aws_security_group.private_sg: Destruction complete after 2s
aws_vpc_endpoint.s3_gateway: Destroying... [id=vpce-0dc52738c6efa7b05]
aws_instance.bastion: Still destroying... [id=i-0e9e19333e83eafb9, 00m31s elapsed]
aws_vpc_endpoint.s3_gateway: Destruction complete after 7s
aws_route_table.private: Destroying... [id=rtb-006ad95be72e4f426]
aws_route_table.private: Destruction complete after 1s
aws_internet_gateway.igw: Still destroying... [id=igw-08921a9f18796bd7a, 00m30s elapsed]
aws_instance.bastion: Still destroying... [id=i-0e9e19333e83eafb9, 00m41s elapsed]
aws_internet_gateway.igw: Still destroying... [id=igw-08921a9f18796bd7a, 00m40s elapsed]
aws_instance.bastion: Still destroying... [id=i-0e9e19333e83eafb9, 00m51s elapsed]
aws_internet_gateway.igw: Still destroying... [id=igw-08921a9f18796bd7a, 00m50s elapsed]
aws_instance.bastion: Still destroying... [id=i-0e9e19333e83eafb9, 01m01s elapsed]
aws_internet_gateway.igw: Still destroying... [id=igw-08921a9f18796bd7a, 01m00s elapsed]
aws_instance.bastion: Destruction complete after 1m11s
aws_key_pair.ec2_kp: Destroying... [id=secure-app-kp]
aws_subnet.public: Destroying... [id=subnet-05a14ddd29f1c19c5]
local_file.kpf: Destroying... [id=fcd2274c6947e38fde19c0ab8fe188c83fce1884]
aws_security_group.bastion_sg: Destroying... [id=sg-04389e150a574b556]
local_file.kpf: Destruction complete after 0s
aws_key_pair.ec2_kp: Destruction complete after 1s
tls_private_key.pk: Destroying... [id=8b36cc32432e01babd85e9eb61108c05d5598982]
tls_private_key.pk: Destruction complete after 0s
aws_internet_gateway.igw: Destruction complete after 1m8s
aws_subnet.public: Destruction complete after 2s
aws_security_group.bastion_sg: Destruction complete after 3s
aws_vpc.network: Destroying... [id=vpc-0ae86da50e32081df]
aws_vpc.network: Destruction complete after 1s
Destroy complete! Resources: 23 destroyed.