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)
- Create two buckets in your R2 dashboard:
my-app-devandmy-app-prod - Create an API token with read/write access, copy
Access Key ID+Secret Access Key - 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
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
- Set up new bucket
- Sync files with
rcloneoraws s3 sync - Update
.env - Restart, verify, delete old bucket
Troubleshooting
- Failed to initialize storage: check
S3_ACCESS_KEYandS3_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)