diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..03ffa5d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,39 @@
+name: Build and Release
+
+on:
+  workflow_run:
+    workflows:
+      - Create Tag
+    types:
+      - completed
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - name: Setup Go
+        uses: actions/setup-go@v4
+        with:
+          go-version-file: go.mod
+      - name: Version
+        run: echo "::set-output name=version::$(cat VERSION)"
+        id: version
+      - name: Build artifacts
+        run: make build
+      - name: Create release
+        uses: softprops/action-gh-release@v1
+        if: ${{ github.event.workflow_run.conclusion == 'success' }}
+        with:
+          draft: false
+          files: |
+            ./dist/darwin_amd64/presign
+            ./dist/darwin_arm64/presign
+            ./dist/linux_amd64/presign
+            ./dist/windows_amd64/presign.exe
+          generate_release_notes: true
+          name: ${{ steps.version.outputs.version }}
+          prerelease: false
+          tag_name: v${{ steps.version.outputs.version }}
diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml
new file mode 100644
index 0000000..2df1d45
--- /dev/null
+++ b/.github/workflows/tag.yml
@@ -0,0 +1,23 @@
+name: Create Tag
+
+on:
+  push:
+    branches:
+      - main
+
+jobs:
+  create_tag:
+    name: Create and Push Tag
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - name: Version
+        run: echo "::set-output name=version::$(cat VERSION)"
+        id: version
+      - name: Tag
+        run: |
+          git config user.name github-actions
+          git config user.email github-actions@github.com
+          git tag -a v${{ steps.version.outputs.version }} -m "${{ steps.version.outputs.version }}"
+          git push origin v${{ steps.version.outputs.version }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..849ddff
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+dist/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..59a89df
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,16 @@
+build: pkg-linux pkg-macos-intel pkg-macos-arm pkg-windows
+
+pkg-linux:
+	GOOS=linux GOARCH=amd64 go build -o dist/linux_amd64/presign
+
+pkg-macos-intel:
+	GOOS=darwin GOARCH=amd64 go build -o dist/darwin_amd64/presign
+
+pkg-macos-arm:
+	GOOS=darwin GOARCH=arm64 go build -o dist/darwin_arm64/presign
+
+pkg-windows:
+	GOOS=windows GOARCH=amd64 go build -o dist/windows_amd64/presign.exe
+
+clean:
+	rm -rf dist
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5906b72
--- /dev/null
+++ b/README.md
@@ -0,0 +1,26 @@
+# aws-presigner
+
+A simple command-line tool to create AWS S3 presigned URLs. The main motivation to create this was
+because creating PUT URLs is not possible via the console or AWS CLI tool.
+
+## Usage
+
+To use, download the executable file and run like this:
+
+```sh
+./presign -b rusher-test -k some-file.txt -m put
+```
+
+If you get a permission error, you made need to update the file permissions
+
+```sh
+chmod +x presign
+```
+
+## Switching environments
+
+This tool loads the default AWS config, so it relies on whichever AWS CLI profile is currently set in your enviroment. To use a different environment without globally changing your aws profile, you can prepend the command to set the `AWS_PROFILE` environment variable for the execution
+
+```sh
+AWS_PROFILE=qa ./presign -b rusher-test -k some-file.txt -m put
+```
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..6e8bf73
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.1.0
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..1b0948f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,27 @@
+module github.com/stori-rusher/aws-presigner
+
+go 1.21.4
+
+require (
+	github.com/aws/aws-sdk-go-v2 v1.24.0
+	github.com/aws/aws-sdk-go-v2/config v1.26.0
+	github.com/aws/aws-sdk-go-v2/service/s3 v1.47.4
+)
+
+require (
+	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
+	github.com/aws/aws-sdk-go-v2/credentials v1.16.11 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/ini v1.7.1 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.18.4 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.4 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.26.4 // indirect
+	github.com/aws/smithy-go v1.19.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..00a5c4a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,38 @@
+github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk=
+github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo=
+github.com/aws/aws-sdk-go-v2/config v1.26.0 h1:uItWWbD/FmHPGSa6GJFyZJD/RPakVjS0fmoq1vccjNw=
+github.com/aws/aws-sdk-go-v2/config v1.26.0/go.mod h1:8Rf77VTcX9MMkoMIsCnuwmef+Y1bs2Zhvw9IXHdD/Po=
+github.com/aws/aws-sdk-go-v2/credentials v1.16.11 h1:Gcut3tJSU7F/C5W/NnFimqnJqljF58rmaw7QlbigN3U=
+github.com/aws/aws-sdk-go-v2/credentials v1.16.11/go.mod h1:CysUbSCfqvEbEQTd9Ubg2RrJy2EFM+AUHJOqqj0guTo=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.7.1 h1:uR9lXYjdPX0xY+NhvaJ4dD8rpSRz5VY81ccIIoNG+lw=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.7.1/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.47.4 h1:iEkLh6fe2ATtH5PGynlJ1SdnbZuZgoWLdvSedjwmqKk=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.47.4/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA=
+github.com/aws/aws-sdk-go-v2/service/sso v1.18.4 h1:2UVO4N/polvKeP+yCA8TLEmidEKxmNTeVpsZnj/bbgA=
+github.com/aws/aws-sdk-go-v2/service/sso v1.18.4/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.4 h1:3JXkQ1F5n73qTpSPas6AQ8/6HFksgnB24JlNPLt3SlM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.4/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38=
+github.com/aws/aws-sdk-go-v2/service/sts v1.26.4 h1:gaRFldXhoT36jVMfQ+AjAYwSfjO5LMgy1u0ObcKFhhc=
+github.com/aws/aws-sdk-go-v2/service/sts v1.26.4/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU=
+github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
+github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..655ea23
--- /dev/null
+++ b/main.go
@@ -0,0 +1,82 @@
+package main
+
+import (
+	"context"
+	_ "embed"
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
+	"github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
+)
+
+//go:embed VERSION
+var version string
+
+func main() {
+	ctx := context.Background()
+
+	var (
+		b, k, m string
+		d       int64
+		v       bool
+	)
+	flag.StringVar(&b, "b", "", "bucket")
+	flag.Int64Var(&d, "d", 0, "duration in seconds (max 604,800)")
+	flag.StringVar(&k, "k", "", "key")
+	flag.StringVar(&m, "m", "", "method (get|put)")
+	flag.BoolVar(&v, "v", false, "version")
+
+	flag.Usage = func() {
+		fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0])
+		flag.PrintDefaults()
+		fmt.Printf("\nVersion:\n  %s\n", version)
+	}
+
+	flag.Parse()
+
+	if v {
+		fmt.Printf("v%s\n", version)
+		os.Exit(0)
+	}
+
+	awsCFG, err := config.LoadDefaultConfig(ctx)
+	if err != nil {
+		log.Fatalf("unable to load S3 config, %v", err)
+	}
+
+	s3Client := s3.NewFromConfig(awsCFG)
+
+	loc, err := s3Client.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: aws.String(b)})
+	if err != nil {
+		log.Fatalf("unable to get bucket location: %v\n", err)
+	}
+
+	p := NewS3Presigner(s3.NewPresignClient(s3Client), string(loc.LocationConstraint))
+
+	if d < 1 || d > 604_800 {
+		log.Fatalf("duration must be between 1 and 604,800 seconds (7 days)")
+	}
+
+	var obj *v4.PresignedHTTPRequest
+	switch strings.ToLower(m) {
+	case "get":
+		obj, err = p.GetObject(ctx, b, k, time.Duration(d)*time.Second)
+	case "put":
+		obj, err = p.PutObject(ctx, b, k, time.Duration(d)*time.Second)
+	default:
+		log.Fatalf("invalid method: %v", m)
+	}
+
+	if err != nil {
+		log.Fatalf("unable to presign request: %v\n", err)
+	}
+
+	fmt.Println(obj.URL)
+}
diff --git a/presigner.go b/presigner.go
new file mode 100644
index 0000000..82e982c
--- /dev/null
+++ b/presigner.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+	"context"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
+)
+
+type Presigner interface {
+	PresignGetObject(context.Context, *s3.GetObjectInput, ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error)
+	PresignPutObject(context.Context, *s3.PutObjectInput, ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error)
+}
+
+// S3Presigner encapsulates the Amazon Simple Storage Service (Amazon S3) presign actions
+// used in the examples.
+// It contains PresignClient, a client that is used to presign requests to Amazon S3.
+// Presigned requests contain temporary credentials and can be made from any HTTP client.
+type S3Presigner struct {
+	client *s3.PresignClient
+	region string
+}
+
+func NewS3Presigner(client *s3.PresignClient, region string) S3Presigner {
+	return S3Presigner{client, region}
+}
+
+// GetObject makes a presigned request that can be used to get an object from a bucket.
+// The presigned request is valid for the specified number of seconds.
+func (p S3Presigner) GetObject(ctx context.Context, bucket, key string, exp time.Duration) (*v4.PresignedHTTPRequest, error) {
+	input := &s3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)}
+
+	return p.client.PresignGetObject(ctx, input, s3.WithPresignExpires(exp), withRegion(p.region))
+}
+
+// PutObject makes a presigned request that can be used to put an object in a bucket.
+// The presigned request is valid for the specified number of seconds.
+func (p S3Presigner) PutObject(ctx context.Context, bucket, key string, exp time.Duration) (*v4.PresignedHTTPRequest, error) {
+	input := &s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)}
+
+	return p.client.PresignPutObject(ctx, input, s3.WithPresignExpires(exp), withRegion(p.region))
+}
+
+// withRegion is a helper function that adds the region to the PresignOptions.
+func withRegion(region string) func(*s3.PresignOptions) {
+	return func(o *s3.PresignOptions) {
+		o.ClientOptions = append(o.ClientOptions,
+			func(o *s3.Options) { o.Region = region },
+		)
+	}
+}