Nov 13, 202611 min read

How to Add File Uploads and Object Storage to a chi App

Store product images from a go-chi API in a public bucket served from a CDN, with Encore or with S3, CloudFront and aws-sdk-go-v2.

Illustration for How to Add File Uploads and Object Storage to a chi App

Product images, avatars and document scans end up in object storage in nearly every API, and in a chi app the handler side is mostly standard library work: cap the body size, check what the bytes are, stream them somewhere. The bucket itself takes longer: one per environment, a CDN in front so images do not come straight from S3, public access settings that keep everything else private, IAM for the app, and something on each laptop that behaves like the bucket.

This guide adds image uploads to a chi product catalog: a PUT stores an image after checking its detected type and size, a GET lists a product's images with CDN URLs, and a DELETE removes one. It starts with Encore, which declares the bucket in Go and provisions it in your own AWS or GCP account, and then shows the same handler with S3, CloudFront and aws-sdk-go-v2.

Object storage options for a chi app at a glance

EncoreS3 + CloudFront by hand
Bucket declared inGo code (objects.NewBucket)Terraform or the console
Local storageLocal disk via encore runLocalStack, MinIO or a real bucket
CDN for public filesCloudFront on AWS, Cloud CDN on GCPCloudFront distribution you configure
App permissionsGranted for the buckets the service usesIAM policy you write
Public URLs in codeimages.PublicURL(key)CDN base URL from an environment variable
CloudAWS or GCP, in your accountAWS

How do you add object storage to chi with Encore?

Encore

Encore works alongside chi, so the upload handlers stay chi handlers and the bucket they write to becomes a Go declaration in the same package. The router is mounted in one raw endpoint with a fallback route, and the object storage API handles uploads, listing, deletes and public URLs for the bucket in every environment.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create catalog-api --example=hello-world$ cd catalog-api && go get github.com/go-chi/chi/v5@v5.5.0$ mkdir -p products/migrations$ encore secret set --type prod,dev,local AuthTokenHMAC

The bucket is public so approved images can be served from a CDN, but the upload, list and delete routes sit behind an auth handler and a product-ownership check. The routes are grouped with r.Route so all three share the {sku} parameter. The upload handler reads the first 512 bytes to detect the type, then streams the rest of the body into the bucket, and aborts the write if the body passes 5 MiB so a partial object is never stored:

// products/images.go package products import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "io" "net/http" "regexp" "strconv" "strings" "time" "encore.dev/beta/auth" "encore.dev/beta/errs" "encore.dev/storage/objects" "encore.dev/storage/sqldb" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) // Local disk with `encore run`, S3 + CloudFront or GCS + Cloud CDN when deployed. var images = objects.NewBucket("product-images", objects.BucketConfig{ Public: true, }) var db = sqldb.NewDatabase("products", sqldb.DatabaseConfig{Migrations: "./migrations"}) var secrets struct { AuthTokenHMAC string } const maxImageBytes = 5 << 20 // 5 MiB var allowedTypes = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", } var sku = regexp.MustCompile(`^[a-z0-9-]{1,40}$`) var objectName = regexp.MustCompile(`^[a-z0-9-]{1,40}\.(jpg|png|webp)$`) //encore:authhandler func AuthHandler(ctx context.Context, token string) (auth.UID, error) { uid, err := userIDFromToken(token) if err != nil { return "", &errs.Error{Code: errs.Unauthenticated, Message: "invalid token"} } return uid, nil } // userIDFromToken verifies a signed access token of the form "<id>.<unix-expiry>.<hex hmac>". // In production, verify a JWT against your IdP JWKS (signature, issuer, audience, // expiry) and return the subject. func userIDFromToken(token string) (auth.UID, error) { if secrets.AuthTokenHMAC == "" { return "", errors.New("auth secret not set") } parts := strings.Split(token, ".") if len(parts) != 3 { return "", errors.New("invalid token") } id, expStr, sig := parts[0], parts[1], parts[2] if id == "" || strings.ContainsAny(id, "/ \t\n") { return "", errors.New("invalid token") } exp, err := strconv.ParseInt(expStr, 10, 64) if err != nil || exp <= time.Now().Unix() { return "", errors.New("invalid token") } mac := hmac.New(sha256.New, []byte(secrets.AuthTokenHMAC)) mac.Write([]byte(id + "." + expStr)) expected := hex.EncodeToString(mac.Sum(nil)) if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) != 1 { return "", errors.New("invalid token") } return auth.UID(id), nil } func authorizeProduct(r *http.Request) (auth.UID, string, error) { uid, ok := auth.UserID() if !ok { return "", "", &errs.Error{Code: errs.Unauthenticated, Message: "unauthenticated"} } product := chi.URLParam(r, "sku") if !sku.MatchString(product) { return "", "", &errs.Error{Code: errs.InvalidArgument, Message: "invalid sku"} } var owner string err := db.QueryRow(r.Context(), `SELECT owner_uid FROM products WHERE sku = $1`, product).Scan(&owner) if err != nil || owner != string(uid) { return "", "", &errs.Error{Code: errs.NotFound, Message: "product not found"} } return uid, product, nil } //encore:service type Service struct { router chi.Router } func initService() (*Service, error) { r := chi.NewRouter() r.Use(middleware.Recoverer) r.Route("/products/{sku}/images", func(r chi.Router) { r.Put("/{name}", uploadImage) r.Get("/", listImages) r.Delete("/{object}", deleteImage) }) return &Service{router: r}, nil } // Fallback sends every request that no Encore endpoint matches to chi. // //encore:api auth raw path=/!fallback func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) { s.router.ServeHTTP(w, req) } func uploadImage(w http.ResponseWriter, r *http.Request) { uid, product, err := authorizeProduct(r) if err != nil { writeAuthError(w, err) return } name := chi.URLParam(r, "name") if !sku.MatchString(name) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid name"}) return } body := http.MaxBytesReader(w, r.Body, maxImageBytes) // Check the real type from the first bytes instead of trusting the header. head := make([]byte, 512) n, err := io.ReadFull(body, head) if err != nil && err != io.ErrUnexpectedEOF { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not read body"}) return } contentType := http.DetectContentType(head[:n]) ext, ok := allowedTypes[contentType] if !ok { writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": "only jpeg, png and webp"}) return } key := "merchants/" + string(uid) + "/" + product + "/" + name + ext wr := images.Upload(r.Context(), key, objects.WithUploadAttrs(objects.UploadAttrs{ ContentType: contentType, })) if _, err := io.Copy(wr, io.MultiReader(bytes.NewReader(head[:n]), body)); err != nil { wr.Abort(err) // nothing is stored if the body was too large or cut off writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "image too large or incomplete"}) return } if err := wr.Close(); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not store image"}) return } writeJSON(w, http.StatusCreated, map[string]string{ "key": key, "url": images.PublicURL(key).String(), }) } func listImages(w http.ResponseWriter, r *http.Request) { uid, product, err := authorizeProduct(r) if err != nil { writeAuthError(w, err) return } prefix := "merchants/" + string(uid) + "/" + product + "/" type image struct { Key string `json:"key"` Size int64 `json:"size"` URL string `json:"url"` } out := []image{} for entry, err := range images.List(r.Context(), &objects.Query{Prefix: prefix, Limit: 100}) { if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list images"}) return } out = append(out, image{Key: entry.Name, Size: entry.Size, URL: images.PublicURL(entry.Name).String()}) } writeJSON(w, http.StatusOK, out) } func deleteImage(w http.ResponseWriter, r *http.Request) { uid, product, err := authorizeProduct(r) if err != nil { writeAuthError(w, err) return } object := chi.URLParam(r, "object") if !objectName.MatchString(object) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object name"}) return } key := "merchants/" + string(uid) + "/" + product + "/" + object err = images.Remove(r.Context(), key) if err != nil && !errors.Is(err, objects.ErrObjectNotFound) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not delete image"}) return } w.WriteHeader(http.StatusNoContent) } func writeAuthError(w http.ResponseWriter, err error) { var e *errs.Error if errors.As(err, &e) { switch e.Code { case errs.Unauthenticated: writeJSON(w, http.StatusUnauthorized, map[string]string{"error": e.Message}) return case errs.NotFound: writeJSON(w, http.StatusNotFound, map[string]string{"error": e.Message}) return } } writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) }

Put this table in products/migrations/1_products.up.sql so each SKU has an owner:

CREATE TABLE products ( sku TEXT PRIMARY KEY, owner_uid TEXT NOT NULL );

The fallback is an auth endpoint, so unauthenticated calls never reach chi. authorizeProduct then checks that the product's owner_uid matches the authenticated user before any upload, list or delete, and object keys live under merchants/{uid}/{sku}/, with delete names limited to the same allowlist as uploads. Approved images are still readable from the public CDN URL the handler returns. With encore run, the bucket lives on local disk and PublicURL points at a local object server, so the whole flow works on a fresh clone. Running it with a signed token, a PNG upload for a product the caller owns returns 201 with a URL that serves the image, a text file gets 415, a 6 MB body gets 413 and leaves nothing behind in the bucket, the list returns the stored image with its size, and the DELETE returns 204.

On deploy, Encore creates the bucket in S3 with CloudFront in front on AWS, or in Cloud Storage with Cloud CDN on GCP, and gives the service permission to use it, so PublicURL returns CDN URLs without a base URL to configure per environment. Private buckets work the same way minus Public: true, with SignedDownloadURL for time-limited links. An agent asked to add thumbnails can declare a second bucket in the same file and test the whole upload path locally before the change is reviewed.

Best fit: chi APIs that store user or product files and want a bucket, a CDN and the app's permissions set up the same way locally, in preview environments and in your own AWS or GCP account.

S3, CloudFront and aws-sdk-go-v2

The hand-built version streams the body to S3 with the transfer manager from aws-sdk-go-v2, which splits large bodies into multipart uploads, and builds public URLs from a CloudFront domain passed in through the environment:

package main import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "crypto/subtle" "database/sql" "encoding/hex" "errors" "io" "log" "net/http" "os" "regexp" "strconv" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/go-chi/chi/v5" _ "github.com/jackc/pgx/v5/stdlib" ) type ctxKey string const userKey ctxKey = "uid" var sku = regexp.MustCompile(`^[a-z0-9-]{1,40}$`) var allowedTypes = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", } func userIDFromToken(token string) (string, error) { secret := os.Getenv("AUTH_TOKEN_HMAC") if secret == "" { return "", errors.New("auth secret not set") } parts := strings.Split(token, ".") if len(parts) != 3 { return "", errors.New("invalid token") } id, expStr, sig := parts[0], parts[1], parts[2] if id == "" || strings.ContainsAny(id, "/ \t\n") { return "", errors.New("invalid token") } exp, err := strconv.ParseInt(expStr, 10, 64) if err != nil || exp <= time.Now().Unix() { return "", errors.New("invalid token") } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(id + "." + expStr)) expected := hex.EncodeToString(mac.Sum(nil)) if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) != 1 { return "", errors.New("invalid token") } return id, nil } func requireMerchant(db *sql.DB) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") uid, err := userIDFromToken(token) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } product := chi.URLParam(r, "sku") if !sku.MatchString(product) { http.Error(w, "invalid sku", http.StatusBadRequest) return } var owner string err = db.QueryRowContext(r.Context(), `SELECT owner_uid FROM products WHERE sku = $1`, product).Scan(&owner) if err != nil || owner != uid { http.Error(w, "product not found", http.StatusNotFound) return } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, uid))) }) } } func main() { cfg, err := config.LoadDefaultConfig(context.Background()) if err != nil { log.Fatal(err) } db, err := sql.Open("pgx", os.Getenv("DATABASE_URL")) if err != nil { log.Fatal(err) } uploader := transfermanager.New(s3.NewFromConfig(cfg)) bucket := os.Getenv("IMAGES_BUCKET") cdnBase := os.Getenv("IMAGES_CDN_URL") // e.g. https://d111111abcdef8.cloudfront.net r := chi.NewRouter() r.With(requireMerchant(db)).Put("/products/{sku}/images/{name}", func(w http.ResponseWriter, r *http.Request) { uid := r.Context().Value(userKey).(string) product, name := chi.URLParam(r, "sku"), chi.URLParam(r, "name") if !sku.MatchString(name) { http.Error(w, "invalid name", http.StatusBadRequest) return } body := http.MaxBytesReader(w, r.Body, 5<<20) head := make([]byte, 512) n, err := io.ReadFull(body, head) if err != nil && err != io.ErrUnexpectedEOF { http.Error(w, "could not read body", http.StatusBadRequest) return } contentType := http.DetectContentType(head[:n]) ext, ok := allowedTypes[contentType] if !ok { http.Error(w, "only jpeg, png and webp", http.StatusUnsupportedMediaType) return } key := "merchants/" + uid + "/" + product + "/" + name + ext _, err = uploader.UploadObject(r.Context(), &transfermanager.UploadObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Body: io.MultiReader(bytes.NewReader(head[:n]), body), ContentType: aws.String(contentType), }) if err != nil { http.Error(w, "could not store image", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) w.Write([]byte(cdnBase + "/" + key)) }) log.Fatal(http.ListenAndServe(":8080", r)) }

The hand-wired route verifies the same signed token in chi middleware, using AUTH_TOKEN_HMAC from the environment, attaches the user ID to the request context, and checks products.owner_uid before it uploads. Size and type checks use http.DetectContentType and the same object-name allowlist, so the route does not store arbitrary bytes as image/png. The infrastructure is the larger part: an S3 bucket with Block Public Access left on, a CloudFront distribution that reads from it through origin access control with a bucket policy that only allows that distribution, and a task role with s3:PutObject, s3:ListBucket and s3:DeleteObject scoped to the bucket. Listing needs ListObjectsV2 with a prefix and pagination, and deleting an image does not clear it from CloudFront until the cache expires or you send an invalidation. Locally you either point the SDK at MinIO or LocalStack with a custom endpoint or give each developer a bucket of their own.

Best fit: teams with S3 and CloudFront already in Terraform, or that need bucket features such as lifecycle rules and replication configured directly.

How to choose

Pick Encore when you want to keep chi and get a bucket declared next to the handlers, local storage that works with encore run, and S3 with CloudFront or Cloud Storage with Cloud CDN provisioned in your own account. Pick S3 and CloudFront by hand when the storage setup already lives in Terraform or depends on bucket settings managed outside the app.

Frequently asked questions

How do I upload files to S3 from a go-chi handler?

Wrap r.Body in http.MaxBytesReader to cap the size, check the type from the first bytes with http.DetectContentType, and stream the body to the bucket instead of reading it all into memory. chi does not parse uploads for you, so the handler works with the standard net/http request.

How do I add object storage to a chi app with Encore?

Declare a bucket with objects.NewBucket("product-images", objects.BucketConfig{Public: true}), stream the request body into images.Upload from your chi handler, and return images.PublicURL(key). The chi router is mounted in a raw endpoint with path=/!fallback, encore run stores objects on local disk, and deploys get S3 with CloudFront or Cloud Storage with Cloud CDN in your own account.

Should a chi API accept uploads or hand out signed URLs?

Accepting the upload in the handler lets you check size and type before anything is stored, which suits images of a few megabytes. For large files such as videos, signed upload URLs let the client send bytes straight to the bucket, and the API confirms the object afterwards.

How do I stop users from uploading files that are not images?

Do not trust the Content-Type header or the file extension. Read the first 512 bytes, run http.DetectContentType on them, compare the result with an allowlist such as image/jpeg, image/png and image/webp, and store the object with that detected type.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders