The traditional, production-ready way to serve a S3 bucket in production is usually to create a CloudFront distribution, add a S3 origin and configure the desired behaviour.

However, this comes with a small caveat: while you can specify an origin path, this will make CloudFront request the bucket from the path directory, then appending the requested URL.

For example, given the test origin path, example.com/index.html will retrieve s3://bucket-name/test/index.html; example.com/folder/index.html will retrieve s3://bucket-name/test/folder/index.html. In other words, the folder structure must match the URL structure, which is not always the desired outcome. While one could use CloudFront Lambda@Edge to overcome this, if CloudFront is not necessary to begin with there is a simpler solution with Kubernetes and ingress-nginx.

In my case, the bucket contains a versioned application as follows:

└── app-name/
    ├── 1.0.0/
    │   └── index.js
    └── 1.0.1/
        └── index.js

I wanted a given URL to point to a given version, so that example.com/folder/index.js would retrieve s3://bucket-name/app-name/1.0.0/index.js. This would not be possible with vanilla CloudFront.

Enter Ingress-Nginx; all we need to do is create a new Ingress as follows:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: s3-bucket-ingress
  namespace: app-frontend
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: "/app-name/1.0.0/$2"
    nginx.ingress.kubernetes.io/upstream-vhost: bucket-name.s3.amazonaws.com
    nginx.ingress.kubernetes.io/from-to-www-redirect: "true"
    nginx.ingress.kubernetes.io/use-regex: "true"
    nginx.ingress.kubernetes.io/backend-protocol: "https"
spec:
  ingressClassName: nginx
  rules:
  - host: example.com
    http:
      paths:
      - path: /folder(/|$)(.*)
        pathType: ImplementationSpecific
        backend:
          service:
            name: s3-bucket-service
            port:
              number: 443

Notice the nginx.ingress.kubernetes.io/rewrite-target specifies the bucket folder name, $2 injects the path from the rule (everything that comes after /folder) and points to its corresponding Service:

kind: Service
apiVersion: v1
metadata:
  name: s3-bucket-service
  namespace: app-frontend
spec:
  type: ExternalName
  externalName: "bucket-name.s3.amazonaws.com"

Thanks to Ingress-Nginx’s Rewrite annotations, content from /app-name/1.0.0 can be served at a different location, similar to what a proxy_pass rule in Nginx would do.

There is no need to enable the static website hosting property in the bucket, but the bucket must be reachable from the Kubernetes cluster. Given the bucket is private, a rule to allow the cluster’s VPC to perform s3.GetObject is enough:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Allow-bucket-VPC",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": ["arn:aws:s3:::bucket-name/*"],
      "Condition": {
        "StringEquals": {
          "aws:sourceVpc": "vpc-123qwe"
        }
      }
    }
  ]
}

Note that you will need to create a VPC endpoint if you do not already have one.