Nov 9, 20267 min read

How to Add S3 File Uploads to a Gin App

Accept multipart uploads, stream files to S3 or Google Cloud Storage and hand out signed upload URLs from a Gin API, with Encore or the AWS SDK for Go.

File uploads are where a Gin API stops being stateless, because files cannot live on the container's disk once there is more than one replica or a deploy replaces the container. The handler side is a few lines of c.FormFile, and the rest is a bucket per environment, credentials that let the service write to it, a local substitute for S3 on every laptop, and a plan for files too large to stream through the API.

This guide adds uploads to a Gin API in three forms: a multipart upload streamed to the bucket, a download that streams the file back, and a signed URL for large files that go straight to storage. It is shown with Encore, which ran the code below with encore run for this article, and with the AWS SDK for Go v2 against Amazon S3.

Object storage options for a Gin app at a glance

EncoreAWS SDK for Go v2 + S3
Bucket declared inGo code (objects.NewBucket)Terraform or the AWS console
Local storageLocal bucket under encore runLocalStack, MinIO or a dev bucket in AWS
CredentialsNone in code; the service's IAM role is provisionedTask role or access keys you configure
Signed upload URLsSignedUploadURLPresignClient.PresignPutObject
CloudsS3 on AWS, Cloud Storage on GCPAWS only

How do you add file uploads to Gin with Encore?

Encore

Encore combines an open source infrastructure SDK with a platform that provisions and deploys into your own AWS or GCP account. Gin keeps handling routing, multipart parsing and responses through a fallback route, and the bucket is a package variable declared next to the handlers.

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 file-api --example=hello-world$ cd file-api && go get github.com/gin-gonic/gin@v1.12.0 github.com/google/uuid$ mkdir files

The service below streams uploads into the bucket with Upload, which returns a writer, serves them back with Download, and signs a 15 minute upload URL for large files. Keys are generated on the server so a client cannot overwrite somebody else's object:

// files/files.go package files import ( "errors" "io" "net/http" "path" "strings" "time" "encore.dev/beta/auth" "encore.dev/storage/cache" "encore.dev/storage/objects" "github.com/gin-gonic/gin" "github.com/google/uuid" ) // S3 on AWS and Cloud Storage on GCP. `encore run` keeps a local bucket. var uploads = objects.NewBucket("uploads", objects.BucketConfig{}) // SignedUploadURL only binds a key and a TTL, so this limits how often a // caller can mint one. The counter is shared by every instance. var uploadLimits = cache.NewCluster("upload-limits", cache.ClusterConfig{ EvictionPolicy: cache.AllKeysLRU, }) var signedUploads = cache.NewIntKeyspace[auth.UID](uploadLimits, cache.KeyspaceConfig{ KeyPattern: "signed-upload/:key", DefaultExpiry: cache.ExpireIn(10 * time.Minute), }) const maxUploadBytes = 10 << 20 // 10 MiB //encore:service type Service struct { router *gin.Engine } func initService() (*Service, error) { r := gin.New() r.Use(gin.Recovery()) r.MaxMultipartMemory = 8 << 20 r.POST("/files", uploadFile) r.GET("/files/:key", downloadFile) r.POST("/files/upload-url", uploadURL) return &Service{router: r}, nil } //encore:api auth raw path=/!fallback func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) { s.router.ServeHTTP(w, req) } func objectKey(name string) string { uid, _ := auth.UserID() return string(uid) + "-" + uuid.NewString() + path.Ext(name) } func uploadFile(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadBytes) fh, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "multipart field 'file' is required (max 10 MiB)"}) return } src, err := fh.Open() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "could not read file"}) return } defer src.Close() // Generate the key on the server so clients cannot overwrite other objects. key := objectKey(fh.Filename) w := uploads.Upload(c.Request.Context(), key, objects.WithUploadAttrs(objects.UploadAttrs{ ContentType: "application/octet-stream", })) if _, err := io.Copy(w, src); err != nil { w.Abort(err) c.JSON(http.StatusInternalServerError, gin.H{"error": "upload failed"}) return } if err := w.Close(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "upload failed"}) return } c.JSON(http.StatusCreated, gin.H{"key": key, "size": fh.Size}) } func downloadFile(c *gin.Context) { ctx := c.Request.Context() uid, _ := auth.UserID() key := c.Param("key") if !strings.HasPrefix(key, string(uid)+"-") { c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } attrs, err := uploads.Attrs(ctx, key) if errors.Is(err, objects.ErrObjectNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } else if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load file"}) return } r := uploads.Download(ctx, key) if err := r.Err(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load file"}) return } defer r.Close() c.Header("Content-Disposition", "attachment") c.Header("X-Content-Type-Options", "nosniff") c.DataFromReader(http.StatusOK, attrs.Size, "application/octet-stream", r, nil) } type uploadURLRequest struct { Filename string `json:"filename" binding:"required"` } // uploadURL returns a short-lived signed URL so large files go straight // from the client to the bucket instead of through the Gin handler. func uploadURL(c *gin.Context) { var req uploadURLRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "filename is required"}) return } uid, _ := auth.UserID() n, err := signedUploads.Increment(c.Request.Context(), uid, 1) if err != nil || n > 5 { c.JSON(http.StatusTooManyRequests, gin.H{"error": "upload url limit exceeded"}) return } key := objectKey(req.Filename) url, err := uploads.SignedUploadURL(c.Request.Context(), key, objects.WithTTL(15*time.Minute)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not sign url"}) return } c.JSON(http.StatusOK, gin.H{"key": key, "upload_url": url.URL}) }

Under encore run, an authenticated POST /files returns a key, GET /files/<key> streams that caller's file back as an attachment, an unknown key returns 404, and a PUT to the signed URL from /files/upload-url stores the file in the local bucket. Nothing in the code holds AWS keys or a bucket name, which differs per environment.

When you deploy, Encore creates an S3 bucket on AWS or a Cloud Storage bucket on GCP with a unique name and a cache cluster for the signed-upload limit, and provisions an IAM role for the service on AWS or a service account on GCP, as described in the object storage docs. Public assets such as avatars can set Public to true on the bucket config and use PublicURL, which Encore serves through CloudFront on AWS or Cloud CDN on GCP. An AI coding agent asked to add thumbnails can declare a second bucket in the same change and test the whole flow against the local bucket, without anyone handing it cloud credentials.

Best fit: Gin APIs that accept user files and want buckets, signed URLs and the service's access to them handled per environment in your own AWS or GCP account.

AWS SDK for Go v2 and S3

With the AWS SDK for Go v2, the upload handler streams the multipart file through the upload manager, which splits large files into parts, and a presign client produces the signed URL:

var ( bucket = os.Getenv("UPLOADS_BUCKET") s3Client *s3.Client uploader *manager.Uploader presign *s3.PresignClient ) func init() { cfg, err := config.LoadDefaultConfig(context.Background()) if err != nil { log.Fatal(err) } s3Client = s3.NewFromConfig(cfg) uploader = manager.NewUploader(s3Client) presign = s3.NewPresignClient(s3Client) } func uploadFile(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 10<<20) fh, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) return } src, err := fh.Open() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "could not read file"}) return } defer src.Close() key := uuid.NewString() + path.Ext(fh.Filename) _, err = uploader.Upload(c.Request.Context(), &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Body: src, ContentType: aws.String(fh.Header.Get("Content-Type")), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "upload failed"}) return } c.JSON(http.StatusCreated, gin.H{"key": key}) }

Beyond the handler, the bucket needs Block Public Access, a CORS rule if browsers PUT to presigned URLs, and lifecycle rules for abandoned multipart uploads; the ECS task role needs s3:PutObject and s3:GetObject on that bucket's ARN; and each environment needs its own bucket name passed in through configuration. Locally, either every developer gets a dev bucket and AWS credentials, or the app points at MinIO or LocalStack with a custom endpoint and path-style addressing, which is a second configuration to maintain. The S3 presigned URL docs note that the URL can only grant what the signing credentials can do, so a narrow task role also narrows what presigned URLs allow.

Best fit: Gin services with buckets already in Terraform, or that need S3 features such as Object Lock, replication or event notifications configured directly.

How to choose

Pick Encore when you want to keep Gin and get buckets, signed URLs and the service's access to them provisioned in your own AWS or GCP account, with uploads that work locally against a local bucket. Pick the AWS SDK with S3 when the bucket and its policies are owned in existing infrastructure code or you need S3 features beyond uploads, downloads and signed URLs.

Frequently asked questions

How do I upload a file to S3 from Gin?

Read the multipart file with c.FormFile("file"), open it, and stream it to S3 with the AWS SDK's upload manager or PutObject instead of reading it into memory. Generate the object key on the server and cap the request size with http.MaxBytesReader so a client cannot overwrite other objects or send unbounded bodies.

How do I limit upload size in Gin?

Wrap the body with c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) before calling c.FormFile. router.MaxMultipartMemory only controls how much of a multipart form Gin keeps in memory before spilling to temporary files, so it does not reject large uploads on its own.

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

Declare a bucket with objects.NewBucket("uploads", objects.BucketConfig{}), mount the Gin router in a raw endpoint with path=/!fallback, and call uploads.Upload, uploads.Download and uploads.SignedUploadURL from the Gin handlers. encore run keeps a local bucket, and on deploy Encore provisions an S3 bucket on AWS or a Cloud Storage bucket on GCP in your own account.

Should large files go through my Gin API?

Files larger than a few megabytes are better sent straight to storage: return a presigned upload URL and let the client PUT the file straight to the bucket, so the upload does not hold a Gin worker or count against request timeouts on the load balancer.

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