Host a static website locally using Simple Storage Service (S3) and Terraform with LocalStack

Host a static website using a Simple Storage Service (S3) bucket to serve static content by provisioning the infrastructure using Terraform in LocalStack. Learn how to configure S3 buckets locally for testing and integration, and make use of LocalStack’s S3 API & tflocal CLI to provision infrastructure locally.

AWS Simple Storage Service (S3) is a proprietary object storage solution that can store an unlimited number of objects for many use cases. S3 is a highly scalable, durable and reliable service that we can use for various use cases: hosting a static site, handling big data analytics, managing application logs, storing web assets and much more!

With S3, you have unlimited storage with your data stored in buckets. A bucket refers to a directory, while an object is just another term for a file. Every object (file) stores the name of the file (key), the contents (value), a version ID and the associated metadata. You can also use S3 to host a static website, to serve static content. It might include HTML, CSS, JavaScript, images, and other assets that make up your website.

LocalStack supports the S3 API, which means you can use the same API calls to interact with S3 in LocalStack as you would with AWS. Using LocalStack, you can create and manage S3 buckets and objects locally, use AWS SDKs and third-party integrations to work with S3, and test your applications without making any significant alterations. LocalStack also supports the creation of S3 buckets with static website hosting enabled.

In this tutorial, we will deploy a static website using an S3 bucket over a locally emulated AWS infrastructure on LocalStack. We will use Terraform to automate the creation & management of AWS resources by declaring them in the HashiCorp Configuration Language (HCL). We will also learn about tflocal, a CLI wrapper created by LocalStack, that allows you to run Terraform locally against LocalStack.

Prerequisites

For this tutorial, you will need:

Creating a static website

We will create a simple static website using plain HTML to get started. To create a static website deployed over S3, we need to create an index document and a custom error document. We will name our index document index.html and our error document error.html. Optionally, you can create a folder called assets to store images and other assets.

Let’s create a directory named s3-static-website-localstack where we’ll store our static website files. If you don’t have an index.html file, you can use the following code to create one:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta http-equiv="Content-Type" content="text/html" />
    <meta  charset="utf-8"  />
    <title>Static Website</title>
  </head>
  <body>
    <p>Static Website deployed locally over S3 using LocalStack</p>
  </body>
</html>

S3 will serve this file when a user visits the root URL of your static website, serving as the default page. In a similar fashion, you can configure a custom error document that contains a user-friendly error message. Let’s create a file named error.html and add the following code:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>404</title>
  </head>
  <body>
    <p>Something is amiss.</p>
  </body>
</html>

S3 will return the above file content only for HTTP 4XX error codes. Some browsers might choose to display their custom error message if a user tries to access a resource that does not exist. In this case, browsers might ignore the above error document. With the initial setup complete, we can now move on to creating a static website using S3 via awslocal, LocalStack’s wrapper for the AWS CLI.

Hosting a static website using S3

To create a static website using S3, we need to create a bucket, enable static website hosting, and upload the files to the bucket. We will use the awslocal CLI for these operations. Navigate to the root directory of the project and create a bucket named testwebsite using LocalStack’s S3 API:

$ awslocal s3api create-bucket --bucket testwebsite

With the bucket created, we can now attach a policy to it to allow public access and its contents. Let’s create a file named bucket_policy.json in the root directory and add the following code:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::testwebsite/*"
        }
    ]
}

Let’s now attach the policy to the bucket:

$ awslocal s3api put-bucket-policy --bucket testwebsite --policy file://bucket_policy.json

With the policy attached, we can now sync the contents of our root directory to the bucket:

$ awslocal s3 sync ./ s3://testwebsite

We’ll now enable static website hosting on the bucket and configure the index and error documents:

$ awslocal s3 website s3://testwebsite/ --index-document index.html --error-document error.html

If you are deploying a static website using S3 on real AWS cloud, your S3 website endpoint will follow one of these two formats:

  • http://<BUCKET_NAME>.s3-website-<REGION>.amazonaws.com
  • http://<BUCKET_NAME>.s3-website.<REGION>.amazonaws.com

In LocalStack, the S3 website endpoint follows the following format: http://<BUCKET_NAME>.s3-website.localhost.localstack.cloud:4566. You can navigate to http://testwebsite.s3-website.localhost.localstack.cloud:4566/ to view your static website.

Orchestrating infrastructure using Terraform

You can automate the above process by orchestrating your AWS infrastructure using Terraform. Terraform is an infrastructure as code (IaC) tool that allows you to create, manage, and version your infrastructure. Terraform uses a declarative configuration language called HashiCorp Configuration Language (HCL) to describe your infrastructure.

Before that, we would need to manually configure the local service endpoints and credentials for Terraform to integrate with LocalStack. We will use the AWS Provider for Terraform to interact with the many resources supported by AWS in LocalStack. Create a new file named provider.tf and specify mock credentials for the AWS provider:

provider "aws" {
  region                      = "us-east-1"
  access_key                  = "fake"
  secret_key                  = "fake"
}

We would also need to avoid issues with routing and authentication (as we do not need it). Therefore we need to supply some general parameters. Additionally, we have to point the individual services to LocalStack. We can do this by specifying the endpoints parameter for each service, that we intend to use. Our provider.tf file should look like this:

provider "aws" {
  access_key                  = "test"
  secret_key                  = "test"
  region                      = "us-east-1"

  # only required for non virtual hosted-style endpoint use case.
  # https://registry.terraform.io/providers/hashicorp/aws/latest/docs#s3_force_path_style
  s3_use_path_style           = false
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    s3             = "http://s3.localhost.localstack.cloud:4566"
  }
}

With the provider configured, we can now configure the variables for our S3 bucket. Create a new file named variables.tf and add the following code:

variable "bucket_name" {
  description = "Name of the s3 bucket. Must be unique."
  type        = string
}

variable "tags" {
  description = "Tags to set on the bucket."
  type        = map(string)
  default     = {}
}

We take a user input for the bucket name and tags. Next, we will define the output variables for our Terraform configuration. Create a new file named outputs.tf and add the following code:

output "arn" {
  description = "ARN of the bucket"
  value       = aws_s3_bucket.s3_bucket.arn
}

output "name" {
  description = "Name (id) of the bucket"
  value       = aws_s3_bucket.s3_bucket.id
}

output "domain" {
  description = "Domain name of the bucket"
  value       = aws_s3_bucket_website_configuration.s3_bucket.website_domain
}

output "website_endpoint" {
  value = aws_s3_bucket_website_configuration.s3_bucket.website_endpoint
}

The output variables are the ARN, name, domain name, and website endpoint of the bucket. With all the configuration files in place, we can now create the S3 bucket. Create a new file named main.tf and create the S3 bucket using the following code:

resource "aws_s3_bucket" "s3_bucket" {
  bucket = var.bucket_name
  tags   = var.tags
}

To configure the static website hosting, we will use the aws_s3_bucket_website_configuration resource. Add the following code to the main.tf file:

resource "aws_s3_bucket_website_configuration" "s3_bucket" {
  bucket = aws_s3_bucket.s3_bucket.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "error.html"
  }

}

To set the bucket policy, we will use the aws_s3_bucket_policy resource. Add the following code to the main.tf file:

resource "aws_s3_bucket_acl" "s3_bucket" {
  bucket = aws_s3_bucket.s3_bucket.id
  acl    = "public-read"
}

resource "aws_s3_bucket_policy" "s3_bucket" {
  bucket = aws_s3_bucket.s3_bucket.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "PublicReadGetObject"
        Effect    = "Allow"
        Principal = "*"
        Action    = "s3:GetObject"
        Resource = [
          aws_s3_bucket.s3_bucket.arn,
          "${aws_s3_bucket.s3_bucket.arn}/*",
        ]
      },
    ]
  })
}

In the above code, we are setting the ACL of the bucket to public-read and setting the bucket policy to allow public access to the bucket. Pick up an appropriate policy based on your use case. Let’s use the aws_s3_object resource to upload the files to the bucket. Add the following code to the main.tf file:

resource "aws_s3_object" "object_www" {
  depends_on   = [aws_s3_bucket.s3_bucket]
  for_each     = fileset("${path.root}", "*.html")
  bucket       = var.bucket_name
  key          = basename(each.value)
  source       = each.value
  etag         = filemd5("${each.value}")
  content_type = "text/html"
  acl          = "public-read"
}

The above code uploads all our html files to the bucket. We are also setting the ACL of the files to public-read. Optionally, if you have static assets like images, CSS, and JavaScript files, you can upload them to the bucket using the same aws_s3_bucket_object resource by adding the following code to the main.tf file:

resource "aws_s3_object" "object_assets" {
  depends_on = [aws_s3_bucket.s3_bucket]
  for_each   = fileset(path.module, "assets/*")
  bucket     = var.bucket_name
  key        = each.value
  source     = "${each.value}"
  etag       = filemd5("${each.value}")
  acl        = "public-read"
}

With all the configuration files in place, we can now initialize the Terraform configuration. Run the following command to initialize the Terraform configuration:

$ terraform init

...
Terraform has been successfully initialized!
...

We can create an execution plan based on our Terraform configuration for the AWS resources. Run the following command to create an execution plan:

$ terraform plan

Finally, we can apply the Terraform configuration to create the AWS resources. Run the following command to apply the Terraform configuration:

$ terraform apply

var.bucket_name
  Name of the s3 bucket. Must be unique.

  Enter a value: testbucket
...
arn = "arn:aws:s3:::testbucket"
domain = "s3-website-us-east-1.amazonaws.com"
name = "testbucket"
website_endpoint = "testbucket.s3-website-us-east-1.amazonaws.com"

In the above command, we specified testbucket as the bucket name. You can specify any bucket name since LocalStack is ephemeral, and stopping your LocalStack container will delete all the created resources. The above command output includes the ARN, name, domain name, and website endpoint of the bucket. You can see the website_endpoint configured to use AWS S3 Website Endpoint. You can now access the website using the bucket name in the following format: http://<BUCKET_NAME>.s3-website.localhost.localstack.cloud:4566. Since the endpoint is configured to use localhost.localstack.cloud, no real AWS resources have been created.

You can optionally use the tflocal CLI as a drop-in replacement for the official Terraform CLI. tflocal uses the Terraform Override mechanism to create a temporary localstack_providers_override.tf file, which is deleted after the infrastructure is created. It mitigates the need to create the provider.tf file manually. You can use tflocal to create the infrastructure by running the following commands:

$ tflocal init
$ tflocal plan
$ tflocal apply

Conclusion

In this tutorial, we have seen how to use LocalStack to create an S3 bucket and configure it to serve a static website. We have also seen how you can use Terraform to provision AWS infrastructure in an emulated local environment using LocalStack. You can use the LocalStack App to view the created buckets and files on the LocalStack Resource dashboard for S3 and upload more files or perform other operations on the bucket. Using LocalStack, you can perform various operations using emulated S3 buckets and other AWS services without creating any real AWS resources.

The code for this tutorial can be found in our LocalStack Terraform samples over GitHub. Please make sure to adjust the paths for the html files in main.tf. Further documentation for S3 is available on our S3 documentation.