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.

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.
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.
Run in your terminal to get started locally.
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.
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.
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.
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.
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.
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.
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.