# AWS VPC Endpoints and PrivateLink with Terraform: Private Access to AWS Services

> Create gateway and interface VPC endpoints with Terraform or OpenTofu to reach S3, DynamoDB and other AWS services privately and reduce NAT gateway costs.

- Source: https://www.itwonderlab.com/terraform-aws-vpc-endpoints/
- Published: 2026-07-08
- Updated: 2026-07-08
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## Reach AWS services without going through the Internet

By default, an instance in a private [subnet](https://www.itwonderlab.com/aws-subnets/) reaches [S3](https://www.itwonderlab.com/aws-s3/) or [SQS](https://www.itwonderlab.com/aws-sqs/) through a [NAT gateway](https://www.itwonderlab.com/aws-terraform-tutorial-aws-nat-gateway/) and the public Internet endpoint of the service. A **VPC endpoint** gives a private path inside the AWS network. It improves security (traffic never leaves AWS), allows subnets without Internet access, and can **reduce NAT gateway data processing charges**, which are significant for S3 traffic.

There are two types, both based on [AWS PrivateLink](https://www.itwonderlab.com/aws-privatelink/) technology:

| | Gateway endpoint | Interface endpoint |
|---|---|---|
| Services | S3 and DynamoDB only | Most AWS services and third-party services |
| How it works | A route in the [route table](https://www.itwonderlab.com/aws-terraform-tutorial-aws-routing-tables/) | A network interface (ENI) with a private IP in your subnets |
| Cost | Free | Hourly charge per endpoint per AZ plus data processed |

### Gateway endpoint for S3 and DynamoDB

```hcl title="endpoints.tf"
data "aws_region" "current" {}

resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.${data.aws_region.current.name}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [for rt in aws_route_table.private : rt.id]
}

resource "aws_vpc_endpoint" "dynamodb" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.${data.aws_region.current.name}.dynamodb"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [for rt in aws_route_table.private : rt.id]
}
```

Terraform adds a route for the service prefix list to the private route tables. There is nothing else to configure in the application, and since it is free you should create these two in almost every VPC. Adjust `aws_route_table.private` to the names in your configuration.

You can restrict what the endpoint allows with an endpoint policy, for example only to your buckets.

### Interface endpoint

```hcl title="endpoints.tf"
resource "aws_security_group" "endpoints" {
  name   = "ditwl-pro-endpoints"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [aws_vpc.main.cidr_block]
  }
}

resource "aws_vpc_endpoint" "secretsmanager" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.${data.aws_region.current.name}.secretsmanager"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = aws_subnet.private[*].id
  security_group_ids  = [aws_security_group.endpoints.id]
  private_dns_enabled = true
}
```

With `private_dns_enabled = true`, the normal service name (`secretsmanager.eu-west-1.amazonaws.com`) resolves to the private IPs of the endpoint inside the VPC, so no application change is needed. This requires `enable_dns_support` and `enable_dns_hostnames` on the VPC ([VPC tutorial](https://www.itwonderlab.com/aws-terraform-tutorial-aws-vpc/)). The [security group](https://www.itwonderlab.com/aws-terraform-tutorial-aws-security-groups/) must allow HTTPS from your instances.

Common interface endpoints: `ssm`, `ssmmessages`, `ec2messages` (for [Session Manager](https://www.itwonderlab.com/aws-systems-manager/) without Internet), `ecr.api`, `ecr.dkr` (for [ECS and ECR](https://www.itwonderlab.com/containers-aws-ecs-terraform-fargate/)), `logs`, `sqs`, `kms`, `secretsmanager`, `sts`.

### Several interface endpoints at once

```hcl title="endpoints.tf"
locals {
  interface_services = ["ssm", "ssmmessages", "ec2messages", "logs", "kms"]
}

resource "aws_vpc_endpoint" "interface" {
  for_each = toset(local.interface_services)

  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.${data.aws_region.current.name}.${each.key}"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = aws_subnet.private[*].id
  security_group_ids  = [aws_security_group.endpoints.id]
  private_dns_enabled = true
}
```

Use [`for_each`](https://www.itwonderlab.com/terraform-for-each-vs-count/) so each endpoint is independent.

### Expose your own service with PrivateLink

You can offer an application running behind a Network Load Balancer to other VPCs or accounts without peering:

```hcl title="privatelink.tf"
resource "aws_vpc_endpoint_service" "app" {
  acceptance_required        = true
  network_load_balancer_arns = [aws_lb.nlb.arn]
}
```

The consumers create an interface endpoint with `service_name = aws_vpc_endpoint_service.app.service_name`. It exposes one service and not the whole network, which solves overlapping CIDR blocks. Compare with [peering and Transit Gateway](https://www.itwonderlab.com/terraform-aws-vpc-peering-transit-gateway/).

### Costs

Interface endpoints are billed per hour **per Availability Zone**, so ten endpoints in three AZs are 30 billed units. Create only those you need, and in as many AZs as the workloads use. Gateway endpoints are free. Check the current [PrivateLink pricing](https://aws.amazon.com/privatelink/pricing/) and estimate with [Infracost](https://www.itwonderlab.com/terraform-cost-estimation-infracost/).
