Nov 11, 20267 min read

How to Add File Uploads with S3 to an Echo App

Accept attachments in a Labstack Echo API, stream them into object storage with size and type checks, and serve them back through short-lived signed URLs, using Encore or the AWS SDK for Go v2 with S3.

Illustration for How to Add File Uploads with S3 to an Echo App

Support tickets, invoices and profile photos all end up as files, and an Echo API that writes them to the container's disk loses them on the next deploy and cannot share them between replicas. Moving them to S3 is the standard fix, which brings a bucket per environment, an IAM policy for the service, a decision about who signs download URLs and a local replacement for S3 on every laptop.

This guide adds attachments to a support-ticket API built on Echo. Uploads are limited to PNG, JPEG and PDF up to 10 MiB, streamed into a private bucket without overwriting existing files, and downloads redirect to a signed URL that expires after five minutes. The code is shown with Encore, then with the AWS SDK for Go v2 against S3.

File storage options for an Echo app at a glance

Encoreaws-sdk-go-v2 + S3
Bucket declared inGo code (objects.NewBucket)Terraform or the AWS console
Local storageFiles on local disk under encore runMinIO, LocalStack or a shared dev bucket
UploadUpload returns a writer you stream intoPutObject, or the upload manager for large files
Signed URLsSignedDownloadURL, SignedUploadURLs3.NewPresignClient
PermissionsGranted to the services that use the bucketIAM policy on the task role
ProductionS3 on AWS, Cloud Storage on GCP, in your accountS3 you provision

How do you add file uploads to Echo with Encore?

Encore

Encore works alongside Echo and creates the buckets, databases and queues your Go code declares, in your own AWS or GCP account. The Echo instance serves every route from a raw fallback route, and the bucket is a package-level variable that the upload and download handlers call directly.

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 helpdesk --example=hello-world$ cd helpdesk && go get github.com/labstack/echo/v4@v4.16.3$ mkdir support
// support/support.go package support import ( "context" "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/hex" "errors" "io" "net/http" "path" "regexp" "strconv" "strings" "time" "encore.dev/beta/auth" "encore.dev/beta/errs" "encore.dev/storage/objects" "encore.dev/storage/sqldb" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) // A private bucket: local files under `encore run`, // S3 on AWS and Google Cloud Storage on GCP when deployed. var Attachments = objects.NewBucket("ticket-attachments", objects.BucketConfig{}) var tickets = sqldb.NewDatabase("tickets", sqldb.DatabaseConfig{Migrations: "./migrations"}) var secrets struct { // HMAC secret used to verify access tokens. AuthTokenHMAC string } //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. Callers cannot impersonate another user without // the HMAC secret. 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 } const maxUpload = 10 << 20 // 10 MiB var allowedTypes = map[string]bool{ "image/png": true, "image/jpeg": true, "application/pdf": true, } var safeName = regexp.MustCompile(`[^a-zA-Z0-9._-]`) //encore:service type Service struct { e *echo.Echo } func initService() (*Service, error) { e := echo.New() e.Use(middleware.Recover()) e.Use(middleware.BodyLimit("11M")) e.POST("/tickets/:id/attachments", upload) e.GET("/tickets/:id/attachments/:name", download) return &Service{e: e}, nil } //encore:api auth raw path=/!fallback func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) { s.e.ServeHTTP(w, req) } func authorizeTicket(c echo.Context) (int64, error) { uid, ok := auth.UserID() if !ok { return 0, echo.NewHTTPError(http.StatusUnauthorized) } id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { return 0, echo.NewHTTPError(http.StatusBadRequest, "invalid ticket id") } var owner string err = tickets.QueryRow(c.Request().Context(), ` SELECT owner_uid FROM tickets WHERE id = $1 `, id).Scan(&owner) if err != nil || owner != string(uid) { return 0, echo.NewHTTPError(http.StatusNotFound) } return id, nil } func objectKey(id int64, name string) string { return "tickets/" + strconv.FormatInt(id, 10) + "/" + safeName.ReplaceAllString(path.Base(name), "_") } func upload(c echo.Context) error { id, err := authorizeTicket(c) if err != nil { return err } fh, err := c.FormFile("file") if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "file is required") } ct := fh.Header.Get("Content-Type") if fh.Size > maxUpload || !allowedTypes[ct] { return echo.NewHTTPError(http.StatusBadRequest, "PNG, JPEG or PDF up to 10 MiB") } key := objectKey(id, fh.Filename) src, err := fh.Open() if err != nil { return err } defer src.Close() // Stream to the bucket and refuse to overwrite an existing attachment. w := Attachments.Upload(c.Request().Context(), key, objects.WithUploadAttrs(objects.UploadAttrs{ContentType: ct}), objects.WithPreconditions(objects.Preconditions{NotExists: true}), ) if _, err := io.Copy(w, src); err != nil { w.Abort(err) return err } if err := w.Close(); err != nil { return echo.NewHTTPError(http.StatusConflict, "an attachment with this name exists") } return c.JSON(http.StatusCreated, map[string]string{"key": key}) } func download(c echo.Context) error { id, err := authorizeTicket(c) if err != nil { return err } key := objectKey(id, c.Param("name")) ctx := c.Request().Context() if _, err := Attachments.Attrs(ctx, key); errors.Is(err, objects.ErrObjectNotFound) { return echo.NewHTTPError(http.StatusNotFound) } else if err != nil { return err } u, err := Attachments.SignedDownloadURL(ctx, key, objects.WithTTL(5*time.Minute)) if err != nil { return err } return c.Redirect(http.StatusFound, u.URL) }

The fallback is an auth endpoint, so unauthenticated calls never reach Echo. authorizeTicket then checks that the ticket's owner_uid matches the authenticated user before any upload or signed URL is issued, so a guessed ticket id cannot expose someone else's attachment. Echo's BodyLimit middleware rejects anything over 11 MB before the multipart form is parsed, the handler then checks the declared size and type, and the object key is built from the numeric ticket ID and a sanitised file name, so a crafted name such as ../../etc/passwd cannot escape the ticket's prefix. The NotExists precondition makes the upload fail instead of silently replacing a file with the same name, and the download handler answers with a redirect to a URL that S3 or Cloud Storage serves directly.

encore run stores objects on the local filesystem, so curl -H "Authorization: Bearer $TOKEN" -F file=@invoice.pdf localhost:4000/tickets/42/attachments works on a fresh clone with no cloud account, as the object storage docs describe. On deploy, Encore creates the bucket in S3 or Google Cloud Storage with a unique name and grants this service the storage operations it uses, as listed in the AWS infrastructure docs, and each preview environment is isolated, with object storage provisioned for that environment. When a coding agent adds thumbnail generation, it can declare a second bucket and a subscriber in the same pull request and exercise both against local storage before review.

Best fit: Echo APIs that accept user files and want a private bucket, signed downloads and local storage that behaves like the cloud, provisioned in their own AWS or GCP account.

aws-sdk-go-v2 and S3

The AWS SDK for Go v2 covers the same flow with a single PutObject for files of this size and a presign client for downloads:

cfg, err := config.LoadDefaultConfig(ctx) if err != nil { log.Fatal(err) } client := s3.NewFromConfig(cfg) presigner := s3.NewPresignClient(client) bucket := os.Getenv("ATTACHMENTS_BUCKET") // In the upload handler (src is the opened multipart file): _, err = client.PutObject(ctx, &s3.PutObjectInput{ Bucket: &bucket, Key: &key, Body: src, ContentLength: aws.Int64(fh.Size), ContentType: &ct, IfNoneMatch: aws.String("*"), // fail if the key exists }) // In the download handler: req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: &bucket, Key: &key, }, s3.WithPresignExpires(5*time.Minute))

The bucket needs Block Public Access, default encryption, a CORS rule if browsers will ever upload directly, and a task role policy allowing s3:PutObject and s3:GetObject on arn:aws:s3:::bucket/tickets/*. S3 has supported conditional writes with If-None-Match since 2024, which replaces the old read-then-write check. A presigned URL is only valid while the credentials that signed it are, so URLs signed with an ECS task role's temporary credentials can stop working before their stated expiry when the session rotates. Locally you either point the SDK at MinIO with a custom endpoint and path-style addressing, or share a dev bucket.

Best fit: teams with an existing S3 bucket and IAM managed in Terraform, or that need S3 features such as Object Lock or replication.

How to choose

Pick Encore to keep Echo and get a private bucket with streaming uploads, signed URLs and local storage under encore run, created as S3 or Cloud Storage in your own account. Pick the AWS SDK with S3 when the bucket already exists in Terraform or you need S3-specific features, and budget for the IAM policy, the local substitute and the credential lifetime of presigned URLs.

Frequently asked questions

How do I upload a file to S3 from an Echo handler?

Read the file with c.FormFile, check its size and Content-Type, open it and send it to S3 with PutObject from the AWS SDK for Go v2 with ContentLength set, using c.Request().Context() so the upload stops if the client disconnects. Set a body limit with Echo's BodyLimit middleware so oversized requests are rejected before they are read.

How do I add file uploads to an Echo app with Encore?

Declare a bucket with objects.NewBucket("name", objects.BucketConfig{}), stream the file into Attachments.Upload(ctx, key) from the Echo handler and close the writer, then serve it with Attachments.SignedDownloadURL(ctx, key, objects.WithTTL(5*time.Minute)). Encore stores files on local disk under encore run and provisions S3 or Google Cloud Storage in your own account on deploy.

Should an Echo API proxy file downloads or redirect to a signed URL?

Redirect to a short-lived signed URL for anything larger than a few kilobytes, after checking that the caller may read the file. The bytes then go straight from S3 to the client, and the API instance does not hold a connection open for the whole download.

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