Get a quote

File Upload Architecture for SaaS: S3 Presigned URLs, Multipart Uploads, and CDN Serving in Go

File upload is one of those features that looks simple until you are dealing with large files, multiple tenants, cost isolation, and CDN delivery. This is how we architect it for SaaS products in Lebanon and MENA.

File upload is one of those features that looks simple until you are dealing with large files, multiple tenants, cost isolation, and CDN delivery. Proxying uploads through your Go backend sounds obvious, but it means every file transits your server, consuming bandwidth and compute you are paying for. The right architecture for SaaS is direct S3 uploads with presigned URLs, tenant-isolated prefixes, and CloudFront for delivery.

Why not proxy through the backend?

The naive approach: client sends file to your Go API, Go API writes it to S3. This works fine for small files and low volume. The problems emerge as you scale:

  • Every upload ties up a goroutine and network bandwidth on your ECS task
  • Large file uploads can exhaust connection limits on smaller Fargate tasks
  • You pay for ingress traffic to your ALB and egress from ECS to S3
  • A slow client upload blocks a server-side goroutine for the duration

With presigned URLs, the client uploads directly to S3 from the browser or mobile app. Your Go backend generates the URL, validates the completed upload, and records metadata. The server never sees the file bytes.

The presigned URL flow

The complete flow for a single file upload:

  1. Client requests an upload URL from your Go API
  2. Go generates a presigned PUT URL with a pre-determined S3 key
  3. Client uploads the file directly to S3 using the presigned URL
  4. S3 triggers an event notification (or client confirms completion)
  5. Go records the file metadata in PostgreSQL
import (
  "github.com/aws/aws-sdk-go-v2/aws"
  "github.com/aws/aws-sdk-go-v2/service/s3"
  "github.com/aws/aws-sdk-go-v2/service/s3/presignv4"
)

type UploadURLRequest struct {
  Filename    string
  ContentType string
  SizeBytes   int64
}

type UploadURLResponse struct {
  UploadURL string
  ObjectKey string
  ExpiresIn int
}

func (s *FileService) GenerateUploadURL(
  ctx context.Context,
  workspaceID string,
  req UploadURLRequest,
) (UploadURLResponse, error) {
  // Validate before generating the URL
  if req.SizeBytes > s.maxFileSizeBytes {
    return UploadURLResponse{}, ErrFileTooLarge
  }
  if !isAllowedContentType(req.ContentType) {
    return UploadURLResponse{}, ErrInvalidContentType
  }

  // Build tenant-isolated S3 key
  fileID := uuid.New().String()
  ext := filepath.Ext(req.Filename)
  objectKey := fmt.Sprintf("workspaces/%s/files/%s%s", workspaceID, fileID, ext)

  presignClient := s3.NewPresignClient(s.s3Client)
  presigned, err := presignClient.PresignPutObject(ctx, &s3.PutObjectInput{
    Bucket:      aws.String(s.bucket),
    Key:         aws.String(objectKey),
    ContentType: aws.String(req.ContentType),
    // Enforce content length — prevents the client from uploading a larger file
    ContentLength: aws.Int64(req.SizeBytes),
  }, func(opts *s3.PresignOptions) {
    opts.Expires = 15 * time.Minute
  })
  if err != nil {
    return UploadURLResponse{}, fmt.Errorf("presign: %w", err)
  }

  // Record the pending upload in PostgreSQL
  err = s.queries.CreatePendingUpload(ctx, db.CreatePendingUploadParams{
    ID:          fileID,
    WorkspaceID: workspaceID,
    ObjectKey:   objectKey,
    Filename:    req.Filename,
    ContentType: req.ContentType,
    SizeBytes:   req.SizeBytes,
    Status:      "pending",
    ExpiresAt:   time.Now().Add(20 * time.Minute),
  })
  if err != nil {
    return UploadURLResponse{}, fmt.Errorf("db: %w", err)
  }

  return UploadURLResponse{
    UploadURL: presigned.URL,
    ObjectKey: objectKey,
    ExpiresIn: 900,
  }, nil
}

Tenant isolation with S3 key prefixes

The key structure workspaces/{workspace_id}/files/{file_id}.ext is intentional. It provides tenant isolation at the storage level. S3 bucket policies and IAM conditions can restrict which prefixes a role can access.

For strict isolation, you can go further with S3 Object Ownership and per-tenant access points, but for most SaaS products a prefix-based strategy with server-side enforcement is sufficient.

Never let clients choose their own S3 key. The key is generated server-side, tied to the authenticated workspace, and recorded before the upload begins. This prevents path traversal attacks where a malicious client might attempt to overwrite another tenant's files.

Confirming the upload and handling failures

After the client uploads to S3, your backend needs to know the upload succeeded. Two approaches:

Option 1: Client confirms to the API after upload

func (s *FileService) ConfirmUpload(
  ctx context.Context,
  workspaceID string,
  fileID string,
) error {
  // Verify the object actually exists in S3
  _, err := s.s3Client.HeadObject(ctx, &s3.HeadObjectInput{
    Bucket: aws.String(s.bucket),
    Key:    aws.String(objectKey),
  })
  if err != nil {
    return ErrUploadNotFound
  }

  // Mark as completed in the database
  return s.queries.ConfirmUpload(ctx, db.ConfirmUploadParams{
    ID:          fileID,
    WorkspaceID: workspaceID,
    Status:      "completed",
    CompletedAt: time.Now(),
  })
}

Option 2: S3 Event Notification to SQS

Configure S3 to send s3:ObjectCreated:* events to an SQS queue. A background Go worker processes these events:

func (w *UploadWorker) Process(ctx context.Context, msg sqs.Message) error {
  var event s3Event
  if err := json.Unmarshal([]byte(*msg.Body), &event); err != nil {
    return fmt.Errorf("parse s3 event: %w", err)
  }

  for _, record := range event.Records {
    objectKey := record.S3.Object.Key
    // Extract workspace ID and file ID from the key
    workspaceID, fileID, err := parseObjectKey(objectKey)
    if err != nil {
      w.logger.Warn("unknown object key", zap.String("key", objectKey))
      continue
    }

    if err := w.fileService.MarkUploadComplete(ctx, workspaceID, fileID); err != nil {
      return fmt.Errorf("mark complete: %w", err)
    }
  }
  return nil
}

The SQS approach is more robust because it handles cases where the client disconnects after uploading to S3 but before calling your confirm endpoint.

Multipart uploads for large files

Files above 100MB should use S3 multipart upload. The client splits the file into parts, uploads each part with a separate presigned URL, then completes the upload.

func (s *FileService) InitiateMultipartUpload(
  ctx context.Context,
  workspaceID string,
  req UploadURLRequest,
) (MultipartUploadResponse, error) {
  objectKey := buildObjectKey(workspaceID)

  create, err := s.s3Client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
    Bucket:      aws.String(s.bucket),
    Key:         aws.String(objectKey),
    ContentType: aws.String(req.ContentType),
  })
  if err != nil {
    return MultipartUploadResponse{}, fmt.Errorf("create multipart: %w", err)
  }

  // Generate presigned URLs for each part (5MB minimum, 5GB maximum per part)
  partCount := int(math.Ceil(float64(req.SizeBytes) / float64(partSizeBytes)))
  partURLs := make([]string, partCount)
  presignClient := s3.NewPresignClient(s.s3Client)

  for i := 1; i <= partCount; i++ {
    presigned, err := presignClient.PresignUploadPart(ctx, &s3.UploadPartInput{
      Bucket:     aws.String(s.bucket),
      Key:        aws.String(objectKey),
      UploadId:   create.UploadId,
      PartNumber: aws.Int32(int32(i)),
    }, func(opts *s3.PresignOptions) {
      opts.Expires = 30 * time.Minute
    })
    if err != nil {
      return MultipartUploadResponse{}, fmt.Errorf("presign part %d: %w", i, err)
    }
    partURLs[i-1] = presigned.URL
  }

  return MultipartUploadResponse{
    UploadID:  *create.UploadId,
    ObjectKey: objectKey,
    PartURLs:  partURLs,
  }, nil
}

CDN delivery with CloudFront

For serving files to users, never generate public S3 URLs. Use CloudFront with Origin Access Control (OAC), which restricts S3 access to CloudFront only. Users cannot bypass CloudFront and access S3 directly.

func (s *FileService) GetDownloadURL(
  ctx context.Context,
  workspaceID string,
  fileID string,
) (string, error) {
  file, err := s.queries.GetFile(ctx, db.GetFileParams{
    ID:          fileID,
    WorkspaceID: workspaceID,
  })
  if err != nil {
    return "", ErrFileNotFound
  }

  // Generate a signed CloudFront URL (not a public S3 URL)
  signer := sign.NewURLSigner(s.cfKeyID, s.cfPrivateKey)
  signedURL, err := signer.Sign(
    fmt.Sprintf("https://%s/%s", s.cdnDomain, file.ObjectKey),
    time.Now().Add(1*time.Hour),
  )
  if err != nil {
    return "", fmt.Errorf("sign cf url: %w", err)
  }

  return signedURL, nil
}

Signed CloudFront URLs expire and cannot be shared publicly. This is important for tenant data isolation: one workspace's files cannot be accessed by another workspace, even if they guess the object key.

Cleanup for failed and expired uploads

The pending upload records in PostgreSQL need a background job to clean them up:

func (w *CleanupWorker) RunCleanup(ctx context.Context) error {
  // Find pending uploads older than 30 minutes
  expired, err := w.queries.GetExpiredPendingUploads(ctx, time.Now().Add(-30*time.Minute))
  if err != nil {
    return fmt.Errorf("get expired: %w", err)
  }

  for _, upload := range expired {
    // Delete the partial S3 object if it exists
    w.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
      Bucket: aws.String(w.bucket),
      Key:    aws.String(upload.ObjectKey),
    })

    if err := w.queries.DeletePendingUpload(ctx, upload.ID); err != nil {
      w.logger.Warn("cleanup: db delete failed", zap.String("id", upload.ID), zap.Error(err))
    }
  }
  return nil
}

Lessons from production

For SaaS products in Lebanon and the MENA region, direct S3 uploads significantly reduce ECS task sizing requirements and egress costs. A restaurant management system handling menu images and receipt PDFs does not need oversized Fargate tasks when uploads bypass the backend entirely. The presigned URL architecture scales to any file volume without changing your backend infrastructure.

The main operational risk is orphaned pending uploads accumulating in your database. The cleanup worker is not optional.


Want to build a proper file handling system for your SaaS?

Voxire architects and builds backend systems for SaaS products across Lebanon and the MENA region. If file upload, storage, or CDN delivery is a bottleneck in your product, we can help design the right architecture.

https://voxire.com/get-a-quote/

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Back to blog
Chat on WhatsApp