Storage & File Uploads

S3-compatible storage for file uploads

Cloud Storage

goilerplate uses S3-compatible storage for all file uploads. Same setup for dev and prod, no surprises.

Recommended: Cloudflare R2. 10GB free forever, zero egress fees. But any S3-compatible service works (AWS S3, DigitalOcean Spaces, Backblaze B2, Wasabi, etc.). Just drop the keys into .env.

Quick Start (R2)

  1. Create two buckets in your R2 dashboard: my-app-dev and my-app-prod
  2. Create an API token with read/write access, copy Access Key ID + Secret Access Key
  3. Drop into .env:
S3_REGION=auto
S3_BUCKET=my-app-dev
S3_ACCESS_KEY=your_r2_access_key
S3_SECRET_KEY=your_r2_secret_key
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
  1. task dev. Done. The bucket is auto-checked on startup.

For prod: same .env shape, swap the bucket name and use prod credentials.

Other Providers

Same env vars, different values:

# AWS S3, leave endpoint empty
S3_REGION=us-east-1
S3_BUCKET=my-app-prod
S3_ACCESS_KEY=AKIA...
S3_SECRET_KEY=...
S3_ENDPOINT=

# DigitalOcean Spaces
S3_REGION=fra1
S3_BUCKET=my-app-prod
S3_ACCESS_KEY=...
S3_SECRET_KEY=...
S3_ENDPOINT=https://fra1.digitaloceanspaces.com

Security Model: Presigned URLs

goilerplate uses presigned URLs for all files, the same pattern Slack and Stripe use.

  • All files live in a private bucket (no public bucket policies)
  • Every access uses a cryptographically signed URL with expiry
  • URLs are generated on-the-fly when pages render

Public files (avatars, profile pics). 7-day expiry, refreshed on each render. Stored under public/.

Private files (documents, user uploads). 1-hour expiry. Stored under private/.

Benefits: works with any S3-compatible service, no IAM gymnastics, files served directly from S3/CDN.

Environment Variables

Variable Required Default Description
S3_REGION Yes - Provider region (e.g. us-east-1, auto for R2)
S3_BUCKET Yes - Bucket name
S3_ACCESS_KEY Yes - Access key from your provider
S3_SECRET_KEY Yes - Secret key from your provider
S3_ENDPOINT No - Custom endpoint (R2/DO/B2/etc.). Leave empty for AWS S3.
S3_PRESIGN_EXPIRY_PUBLIC No 168h Expiry for public files (avatars)
S3_PRESIGN_EXPIRY_PRIVATE No 1h Expiry for private files

Architecture

All storage operations go through a clean interface:

type FileStorage interface {
    Save(path string, file io.Reader) error
    Delete(path string) error
    URL(path string) string
}

Switching providers = changing .env. No code changes.

File Metadata

type File struct {
    ID              string
    UserID          string
    Type            string
    Filename        string
    StoragePath     string  // S3 object key (e.g., "public/avatars/user-123.jpg")
    Public          bool    // true = public file (7d expiry), false = private (1h expiry)
    OriginalName    string
    MimeType        string
    Size            int64
    CreatedAt       time.Time
}

Layout: public/avatars/xyz.jpg, private/documents/xyz.pdf.

Migration Between Providers

  1. Set up new bucket
  2. Sync files with rclone or aws s3 sync
  3. Update .env
  4. Restart, verify, delete old bucket

Troubleshooting

  • Failed to initialize storage: check S3_ACCESS_KEY and S3_SECRET_KEY
  • AccessDenied: verify your API token has read/write on the bucket
  • NoSuchBucket: bucket is auto-created on startup; if it persists, check token permissions
  • Broken URLs: AWS S3 needs empty S3_ENDPOINT, all other providers need their endpoint URL

Security Best Practices

  • Never commit credentials, use .env (gitignored)
  • Rotate keys regularly
  • Keep bucket private (presigned URLs handle access)
  • Validate file types and sizes before upload

Advanced: Owner-Only Files

Presigned URLs can be shared. For true owner-only access (medical records, financial docs), check ownership in middleware before generating the URL:

if file.UserID != user.ID {
    return http.Error(w, "Forbidden", 403)
}
url := storage.PresignedURL(file.StoragePath, 5*time.Minute)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)