CLI tool
bep/s3deploy avatar
bep/s3deploy

s3deploy: shipping a static site to S3 and CloudFront from the command line

A simple tool to deploy static websites to Amazon S3 and CloudFront with Gzip and custom headers support (e.g. "Cache-Control")

551 stars42 forksGoMIT

At a glance

What is it?
s3deploy is a small Go program that uploads a static site to S3, skips files whose ETag has not changed, applies per-route Cache-Control headers and gzip settings, and can invalidate a CloudFront distribution afterwards. It is the kind of tool that exists because the obvious alternative does not do exactly what you need.
Who is it for?
Adopt s3deploy if you deploy a static site to S3 and want change detection that avoids re-uploading unchanged files, plus per-path cache and gzip rules. Do not adopt it expecting a general purpose deployment pipeline, since it moves files to a bucket and nothing more.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 62 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

What s3deploy does, and the one idea that makes it fast

s3deploy is a simple tool to deploy static websites to Amazon S3 and CloudFront with Gzip and custom headers support, for example Cache-Control. It uses ETag hashes to check if a file has changed, which the README says makes it optimal in combination with static site generators like Hugo.

That ETag detail is the design centre of the tool. A static site generator rebuilds your whole site on every commit, and most of those files are byte-identical to the ones already in the bucket. Uploading them again is wasted time and, on S3, it resets the metadata you set. Comparing ETags lets the tool skip unchanged files entirely.

The other half of the value is the routing. The tool can apply different headers and different compression rules to different paths of the site, which is what you need for a real static site where a hashed JavaScript file should be cached for a year and an HTML file should not be cached at all. The README also flags the underlying constraint, that the list of system-defined metadata S3 supports and returns as HTTP headers when hosting a static site is very short, and points to an issue comment for the cases where you need more advanced headers such as security headers.

The README notes it is a good fit with a continuous integration tool such as CircleCI, with a linked tutorial that uses it that way.

The licence is MIT, the primary language is Go, the last push was on 2026-08-22, and the recent releases are v2.16.0 on 2026-04-09, v2.15.0 on 2026-02-15 and v2.14.0 on 2026-01-19. The project status badge says active, that it has reached a stable, usable state and is being actively developed.

Installing it, and the configuration precedence rules

Pre-built binaries are published on the releases page, and since s3deploy is a Go application you can also install it with the Go toolchain:

bash
 go install github.com/bep/s3deploy/v2@latest
 

There is a Homebrew cask for macOS:

bash
brew install --cask bep/tap/s3deploy

The Homebrew route has a caveat attached. It requires sudo, with a link to an issue for more context, and it installs a signed and notarized package.

The configuration precedence is the part worth memorising, because the order is not arbitrary. A flag can be set in one of four places, in priority order: as a flag on the command line, as an OS environment variable prefixed with S3DEPLOY_, as a key and value in .s3deploy.yml, and, for key and secret resolution only, the standard AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables, along with AWS_SESSION_TOKEN.

That last one exists for a specific reason. Because the AWS credentials are read from the usual environment variables, the tool works with AWS Vault and similar tools with no special handling, since a tool that shells out to the AWS CLI would not.

The config file also supports environment variable expansion in the form of a variable reference, which is expanded before the file is parsed:

yaml
path: "${MYVARS_PATH}"
max-delete: "${MYVARS_MAX_DELETE@U}"

And there is a special syntax worth noting, the @U marker for the unquote syntax on an int field. Without it, the value would arrive as a quoted string and fail to parse as a number.

The default config file name is .s3deploy.yml, and the default source path is the current directory.

Routes, and how Cache-Control and gzip are decided per path

The routes section is where s3deploy does something a plain upload cannot.

A route matches files with a regular expression, and each route can apply headers and gzip. The header values are the most notable use, with Cache-Control the obvious one, and the README points out the limitation of S3's system-defined metadata for advanced cases such as security headers. The gzip setting, set to true, compresses the content when it is stored in S3 and sets the correct Content-Encoding when the object is fetched.

A worked example shows the pattern most static sites need:

yaml
routes:
    - route: "^.+\\.(js|css|svg|ttf)$"
      #  cache static assets for 1 year.
      headers:
         Cache-Control: "max-age=31536000, no-transform, public"
      gzip: true
    - route: "^.+\\.(png|jpg)$"
      headers:
         Cache-Control: "max-age=31536000, no-transform, public"
      gzip: false
    - route: "^.+\\.(html|xml|json)$"
      gzip: true

The ordering of the routes matters, because the first matching route wins, and the example is ordered from most specific to least: compiled assets, then images, then the documents. The no-transform directive in the Cache-Control value is worth pointing out, since without it an intermediary is free to alter the asset, which defeats the point of hashing the filename.

Two path-handling options complete the picture. The -skip-local-dirs and -skip-local-files options match against a relative path from the source directory with Unix-style separators, where the source directory is represented by a dot and the rest starts with a slash, and both are repeatable for multiple patterns. Their defaults skip dot-prefixed directories and .DS_Store files, which is the usual set of things that should never end up on a public bucket.

And -strip-index-html removes index.html from all directories except the root entry, which simplifies the cloud configuration for some use cases such as CloudFront distributions with S3 bucket origins.

The flags, CloudFront invalidation and the IAM policy you need

The flag list in the README is the complete surface of the tool, and a few entries are worth pulling out.

-try is a trial run with no remote updates, and it is the flag to use first on any unfamiliar bucket. -force uploads even if the ETags match. -max-delete sets the maximum number of files to delete per deploy, with a default of 256, which is a safety limit rather than a performance one. -workers sets the number of workers to upload files, defaulting to -1, and -acl provides an ACL for uploaded objects, where public-read makes objects public and the default is private. There is also a deprecated -public-access flag that says to use the ACL form instead, and -endpoint-url for pointing at a different endpoint, which is what you would use against a local S3 compatible service.

The CloudFront part is the -distribution-id flag, an optional CDN distribution ID for cache invalidation, repeatable for multiple distributions.

The README has separate sections for the example IAM policy and for the example IAM policy with the CloudFront config, and having them separate is the point rather than an accident. Uploading objects, deleting objects, and invalidating a CloudFront distribution are three different permissions, and the CloudFront one in particular tends to be the one people forget. A policy that grants the first two will deploy successfully and then serve stale content, which is a confusing failure to debug because the deploy itself reports success.

For global AWS configuration, the README points at the AWS SDK for Go documentation on sessions from shared config, and notes the SDK falls back to credentials in the credentials file in your home directory. If you set the AWS_SDK_LOAD_CONFIG environment variable, it also loads shared config from the config file in that directory.

The repository layout and how the tool is tested

The repository is small and the structure tells you how the project is built and verified.

At the top level there is main.go and main_test.go, a lib/ directory, a go.mod and go.sum, a Makefile, a codecov.yml, a .circleci directory, a .github directory, hugoreleaser.yaml with a hugoreleaser.env alongside it, and two files that are worth reading as a pair. testscripts/ holds the test scripts, and watch_testscripts.sh watches them, which is a distinctive way of testing a command line tool.

The testscripts pattern is the one used by several Go projects for testing binaries end to end. Each script is a text file containing a sequence of commands and their expected output, and the test harness runs the real binary and compares what happened with what the script says should have happened. For a tool whose whole job is interacting with a cloud bucket, that is a good fit, because it lets you test the argument parsing, the output format, the routing logic and the diffing without needing an S3 endpoint, by asserting on what the tool would do.

The Makefile is minimal and shows the release process. The build target runs the Go build, install runs the Go install, and release creates an annotated git tag with the version and pushes it with the tags. The hugoreleaser configuration handles the cross-platform binary builds that the releases page publishes, and the environment file next to it holds the settings.

The dependency list in go.mod is worth a quick look, because it shows what the tool is built on. The AWS SDK for Go v2 is split into separate modules for the base, config, credentials, the CloudFront service and the S3 service, so the binary pulls in only the two AWS services it actually calls. Beyond that there is the predicate helper library, a memfile package for in-memory file handling, the quicktest and go-internal test helpers, a ULID package, a file format library, the sync and text packages, and YAML v2 for the config file.

The go directive is 1.26, so the tool needs a current Go toolchain to build from source.

Editorial conclusion

Adopt s3deploy if you deploy a static site to S3 and want change detection that avoids re-uploading unchanged files, plus per-path cache and gzip rules. Do not adopt it expecting a general purpose deployment pipeline, since it moves files to a bucket and nothing more. Before your first run, use the -try flag to see what it would do, and check the example IAM policy in the README, because the permissions needed for uploads, deletions and CloudFront invalidation are different from each other and an over-broad policy is the easy mistake here.

Frequently asked questions

What does s3deploy do and why does it use ETag hashes?

It deploys static websites to Amazon S3 and CloudFront with gzip and custom header support, and it uses ETag hashes to check whether a file has changed, which the README says makes it optimal with static site generators like Hugo, since unchanged files are skipped instead of re-uploaded.

How do I install s3deploy?

From the pre-built binaries on the releases page, with go install github.com/bep/s3deploy/v2@latest, or with brew install --cask bep/tap/s3deploy on macOS, which requires sudo and installs a signed and notarized package.

In what order does s3deploy read its configuration?

In priority order: as a command line flag, as an OS environment variable prefixed with S3DEPLOY_, as a key and value in .s3deploy.yml, and for key and secret resolution the standard AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN variables, which is what makes it work with AWS Vault without special handling.

How do I set different Cache-Control headers for different files in s3deploy?

Use routes in the config file, where each route matches files with a regular expression and can set headers and gzip. The README's example groups compiled assets with a one year max-age and gzip enabled, images with the same max-age and gzip disabled, and html, xml and json with gzip enabled.

What flags should I use on a first s3deploy run?

Start with -try, which is a trial run with no remote updates. Other useful ones are -force to upload even when the ETags match, -max-delete to cap how many files are deleted per deploy at a default of 256, -workers for the upload concurrency, and -distribution-id for CloudFront cache invalidation, which is repeatable for multiple distributions.

Official sources

  1. bep/s3deploy on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/bep-s3deploy.svg)](https://hysenlabs.com/projects/bep-s3deploy)

Was this useful?