Private S3 access from a VPC
Chapter 1 built a private street with no road to the gate. Chapter 2 showed a private passage that leads only to S3. This chapter puts the two together: a server on a private street, a door lock that lets SSH in only from inside the VPC, an S3 gateway endpoint, and a bucket that only accepts requests arriving through that endpoint.
We ran this lab in a fresh VPC called revise-vpc. It uses the same 10.0.0.0/16 range and the same private-street idea as learn-vpc, so only the IDs differ from chapter 1.
1. What we built
- VPC
- vpc-02aaef3395ad8700a · 10.0.0.0/16
- Private subnet
- subnet-0c55e2a950c52a527
- EC2 instance "app"
- i-0a7c9294be8dc5013 · t3.micro · 10.0.2.24
- Security group
- sg-0a90896cc897f783f (app-sg)
- Route table
- rtb-026300361e33b07b0
- S3 gateway endpoint
- vpce-08eeb1e7ed36e7973
- Instance Connect Endpoint
- eice-0f0df3f7760bf2084
- Bucket
- revise-vpc-lab-k7q3m9x2
2. The big picture
Everything AWS-side lives inside the VPC box. There is no internet gateway or NAT route, so the only ways in or out are the two endpoints: the Instance Connect Endpoint for your SSH session and the S3 gateway endpoint for S3 traffic.
The Instance Connect Endpoint is the one new piece. Like the interface endpoints in chapter 2, it is a network card with a private IP inside your subnet (10.0.2.201). Its only job is to carry your browser's SSH session to the server, so you never need a public IP or an Internet Gateway to log in.
Orange: how you get a shell on the instance. Green: S3 traffic that is allowed. Red dashed: paths that fail.
3. Where the server sits
An EC2 instance is a virtual machine. It has a network card (an ENI) placed in one subnet, and that card gets the private IP 10.0.2.24. It has no public IP, so nothing on the internet can address it directly.
- Where it can send traffic is decided by the subnet's route table, not by the instance itself.
- Who may talk to it is decided by its security group, a firewall attached to the network card.
- What AWS APIs it can call (like S3) is decided by the IAM role attached to it. The AWS CLI picks up temporary credentials from that role automatically, which is why
aws s3 lsworks without keys on the box.
4. The door lock: security groups
Chapter 1 called the security group the lock on each house door. Here is a real one. A security group is a list of allow rules. There are no deny rules: anything not matched is dropped. Each inbound rule has four parts, which are exactly the fields you saw on the Edit inbound rules page.
| Field | Your rule | Meaning |
|---|---|---|
| Type | SSH | A preset that fills in protocol and port. |
| Protocol / Port | TCP / 22 | Which kind of traffic the rule matches. |
| Source | 10.0.0.0/16 | Who may send it. Here, any address inside the VPC. |
| Description | ssh from inside | A label for humans. No effect on traffic. |
Saving the page replaces the group's inbound list, and the change takes effect within seconds on every instance using that group, with no reboot.
Why this group is needed
- It is what lets you in at all. The Instance Connect Endpoint sits inside the VPC and opens the SSH connection from its own private IP (
10.0.2.201, visible in "Last login … from 10.0.2.201"). Without a rule allowing port 22 from that address, the browser terminal would hang. - It keeps everyone else out. Because the source is
10.0.0.0/16rather than0.0.0.0/0, nothing outside the VPC can reach SSH even if a public route were added later. - It is stateful. Replies to allowed traffic go back out without needing a matching outbound rule, and replies to the instance's own outbound requests (like its S3 calls) come back in without an inbound rule. That is why you don't need an inbound HTTPS rule for S3 to work.
Tighter option: set the source to the Instance Connect Endpoint's own security group instead of the whole VPC range. Then only the endpoint can SSH in, not every machine in the VPC.
5. How an S3 request travels
When you ran aws s3 cp t.txt s3://revise-vpc-lab-k7q3m9x2/ on the instance, this is what happened:
- DNS. The CLI looks up the regional S3 hostname. It still gets a normal public S3 IP address. A gateway endpoint doesn't change DNS.
- Leaving the instance. The packet is headed for a public IP, and the security group's default outbound rule allows it.
- Route lookup. The route table finds the most specific match. That IP is in the S3 prefix list (
pl-…), and that row points atvpce-08eeb1e7ed36e7973. This is the prefix-list route from chapter 2. AWS added it when you created the endpoint and picked this route table. - Gateway endpoint. The traffic goes to S3 over the AWS network, never touching the internet. S3 sees that the request came through this endpoint, which sets the
aws:SourceVpcevalue the bucket policy checks. - Authorization. S3 checks the instance's IAM role, the endpoint policy (default: full access), and the bucket policy. All three allow it, so the upload succeeds.
How we created the gateway endpoint
- VPC console › Endpoints › Create endpoint.
- Type: AWS services. Service:
com.amazonaws.eu-north-1.s3, the one with type Gateway. - VPC: revise-vpc. Route tables: tick
rtb-026300361e33b07b0. - Policy: Full access › Create endpoint.
aws ec2 create-vpc-endpoint \
--vpc-id vpc-02aaef3395ad8700a \
--service-name com.amazonaws.eu-north-1.s3 \
--vpc-endpoint-type Gateway \
--route-table-ids rtb-026300361e33b07b0
A gateway endpoint has no network card or IP of its own and costs nothing. It only works for S3 (and DynamoDB) in the same region, which is why curl https://www.google.com still times out: Google's IPs aren't in the S3 prefix list, and there's no default route.
6. The bucket's lock
Block Public Access is on for all four settings, so the bucket can never be made public by ACL or policy. The bucket policy adds one deny statement:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "DenyUnlessViaVpceOrAdmin",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::revise-vpc-lab-k7q3m9x2",
"arn:aws:s3:::revise-vpc-lab-k7q3m9x2/*"
],
"Condition": {
"StringNotEquals": { "aws:SourceVpce": "vpce-08eeb1e7ed36e7973" },
"ArnNotEquals": { "aws:PrincipalArn": "arn:aws:iam::270022337485:root" }
}
}]
}
Both conditions must be true for the deny to apply. Read it as: deny unless the request came through our endpoint, or the caller is the root user. An explicit deny beats any allow, so even an IAM user with full S3 permissions is blocked from outside the VPC.
7. How it connects to the outside
| From | To | Path | Result |
|---|---|---|---|
| Your browser | EC2 shell | Console → Instance Connect Endpoint → SSH :22, allowed by app-sg | works |
| EC2 | S3 bucket | Route table → gateway endpoint → S3 | works |
| EC2 | Internet | No 0.0.0.0/0 route, no NAT or internet gateway | times out |
| Internet | EC2 | No public IP; SSH source limited to 10.0.0.0/16 | unreachable |
| Laptop as dev-user | S3 bucket | Public S3 endpoint, no SourceVpce | AccessDenied |
| Console / CloudShell (root) | S3 bucket | Public S3 endpoint, root exempt | works |
8. Tests we ran
[ec2-user@ip-10-0-2-24 ~]$ aws s3 cp t.txt s3://revise-vpc-lab-k7q3m9x2/ --region eu-north-1
upload: ./t.txt to s3://revise-vpc-lab-k7q3m9x2/t.txt
[ec2-user@ip-10-0-2-24 ~]$ aws s3 ls s3://revise-vpc-lab-k7q3m9x2/ --region eu-north-1
2026-11-06 18:58:17 6 t.txt
Still to check: run the same aws s3 ls from your laptop as dev-user and confirm it returns AccessDenied.
Remember only this
- The route table decides where the server can go: here, only inside the VPC, and to S3 through the endpoint.
- The security group decides who can reach the server: here, SSH only from inside the VPC.
- The bucket policy decides who can reach the data: here, only requests that came through our endpoint, plus root.
That completes the networking part: one VPC, a public and a private street, two kinds of door, and a private path to S3 that never touches the internet.