Storage
> Store and retrieve files with Velocity's unified storage manager for local filesystem and Amazon S3.
Velocity provides a unified storage interface for file operations across different backends including local filesystem and Amazon S3. The storage system uses a driver-based architecture that allows you to switch storage backends through configuration without changing your code.
Storage is exposed as a manager that holds one or more named disks. You obtain the manager from the request context (c.Storage()) or from app.Services.Storage, select a disk with Disk(name) or Default(), and call methods on the returned driver. Every I/O method comes in two forms: a context-aware Ctx variant that threads the request’s context.Context through to the backend (so a slow S3 GET or a hung write can be cancelled), and a deprecated non-Ctx shim. New code should always call the Ctx variants.
Quick Start
.env file (a local disk is always configured, and an s3 disk is added when AWS_BUCKET is set). Retrieve it with c.Storage() inside a handler.import "github.com/velocitykode/velocity/router"
func uploadAvatar(c *router.Context, userID int, imageData []byte) error {
disk, err := c.Storage().Default()
if err != nil {
return err
}
path := fmt.Sprintf("avatars/user-%d.jpg", userID)
return disk.PutCtx(c.Request.Context(), path, imageData)
}
func getAvatar(c *router.Context, userID int) ([]byte, error) {
disk, err := c.Storage().Default()
if err != nil {
return nil, err
}
path := fmt.Sprintf("avatars/user-%d.jpg", userID)
return disk.GetCtx(c.Request.Context(), path)
}
func deleteAvatar(c *router.Context, userID int) error {
disk, err := c.Storage().Default()
if err != nil {
return err
}
path := fmt.Sprintf("avatars/user-%d.jpg", userID)
return disk.DeleteCtx(c.Request.Context(), path)
}import (
"io"
"os"
"github.com/velocitykode/velocity/router"
)
func uploadVideo(c *router.Context, videoPath string) error {
disk, err := c.Storage().Default()
if err != nil {
return err
}
// Open file for streaming
file, err := os.Open(videoPath)
if err != nil {
return err
}
defer file.Close()
// Stream large files efficiently
return disk.PutStreamCtx(c.Request.Context(), "videos/intro.mp4", file)
}
func downloadVideo(c *router.Context, outputPath string) error {
disk, err := c.Storage().Default()
if err != nil {
return err
}
// Get file as stream
stream, err := disk.GetStreamCtx(c.Request.Context(), "videos/intro.mp4")
if err != nil {
return err
}
defer stream.Close()
// Write to file
out, err := os.Create(outputPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, stream)
return err
}import (
"time"
"github.com/velocitykode/velocity/router"
)
func handleDownload(c *router.Context) error {
disk, err := c.Storage().Disk("s3")
if err != nil {
return err
}
reportPath := "reports/monthly-report.pdf"
// Permanent public URL (pure string operation, no context needed).
publicURL := disk.URL(reportPath)
_ = publicURL
// Temporary signed URL valid for 30 minutes (S3 only).
url, err := disk.TemporaryURLCtx(c.Request.Context(), reportPath, 30*time.Minute)
if err != nil {
return err
}
return c.JSON(200, map[string]string{
"download_url": url,
})
}Configuration
Storage is configured from environment variables when your app is built. A local disk is always configured; an s3 disk is added automatically when AWS_BUCKET is set.
# Default disk
STORAGE_DRIVER=local # Options: local, s3
# Local disk configuration
FILESYSTEM_LOCAL_ROOT=./storage/app
# S3 disk configuration (only configured when AWS_BUCKET is set)
AWS_BUCKET=my-app-bucket
AWS_DEFAULT_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_URL=https://my-bucket.s3.amazonaws.comSTORAGE_DRIVER value names the default disk (local or s3). FILESYSTEM_LOCAL_ROOT defaults to ./storage/app. Relative roots are resolved against the working directory at startup.For full programmatic control, build a storage.Config and configure a manager yourself (see Custom Disk Configuration).
Drivers
Drivers register themselves into the storage registry from an init(). The local and memory drivers live in the storage package itself; the s3 driver lives in github.com/velocitykode/velocity/storage/s3 and registers the "s3" driver when that package is imported (Velocity imports it for you when an app needs S3).
Local Driver
The local driver stores files on your server’s filesystem:
- Storage location: Configured via
FILESYSTEM_LOCAL_ROOT(default:./storage/app) - Sandboxed: The driver opens an
os.Rootat the configured directory and resolves every path beneath it, rejecting traversal and symlink escapes at the kernel level - Thread-safe: Concurrent operations are properly synchronized
- Cross-platform: Handles path separators automatically
Use cases:
- Development and testing
- Small-scale deployments
- Files that don’t need distributed storage
os.Root file descriptor for the lifetime of the driver. The storage manager drains it during app shutdown via Manager.Shutdown(ctx), so you do not normally release it yourself.S3 Driver
The S3 driver stores files on Amazon S3:
- Scalable: Handle unlimited file storage
- Distributed: Access from anywhere
- Secure: Supports private files with signed URLs
- Efficient: Uses multipart uploads for large files
Use cases:
- Production deployments
- Large file storage
- Global content delivery
- Backup and archival
Memory Driver
The memory driver stores files in memory (useful for tests and ephemeral data):
- Fast: No disk I/O overhead
- Clean: No filesystem pollution
- Limited: Configurable size limit via
MaxSize(default 100 MB)
Use cases:
- Unit testing
- Integration testing
- Development with mock data
API Reference
All methods below are called on a Driver obtained from c.Storage().Disk(name) or c.Storage().Default(). The examples assume a disk variable holding that driver and ctx holding the request context (c.Request.Context()).
File Operations
PutCtx
Store file contents at a given path:
// Store byte array on the default disk
imageData := []byte{ /* ... */ }
err := disk.PutCtx(ctx, "uploads/image.jpg", imageData)
// Using a specific disk
s3, err := c.Storage().Disk("s3")
if err != nil {
return err
}
err = s3.PutCtx(ctx, "backups/data.json", jsonData)PutStreamCtx
Store a file from an io.Reader (efficient for large files):
file, _ := os.Open("large-video.mp4")
defer file.Close()
err := disk.PutStreamCtx(ctx, "videos/tutorial.mp4", file)GetCtx
Retrieve file contents as a byte array:
contents, err := disk.GetCtx(ctx, "uploads/document.pdf")
if err != nil {
if errors.Is(err, storage.ErrFileNotFound) {
// Handle missing file
}
return err
}GetStreamCtx
Retrieve a file as an io.ReadCloser (efficient for large files):
stream, err := disk.GetStreamCtx(ctx, "videos/tutorial.mp4")
if err != nil {
return err
}
defer stream.Close()
// Process stream
_, err = io.Copy(outputFile, stream)ExistsCtx
Check if a file exists (returns a bool):
if disk.ExistsCtx(ctx, "uploads/avatar.jpg") {
// File exists
}DeleteCtx
Delete one or more files:
// Delete single file
err := disk.DeleteCtx(ctx, "uploads/temp.txt")
// Delete multiple files
err := disk.DeleteCtx(ctx,
"uploads/file1.txt",
"uploads/file2.txt",
"uploads/file3.txt",
)File Management
CopyCtx
Copy a file to a new location:
err := disk.CopyCtx(ctx, "uploads/original.jpg", "uploads/copy.jpg")MoveCtx
Move a file to a new location:
err := disk.MoveCtx(ctx, "uploads/temp.jpg", "uploads/final.jpg")SizeCtx
Get the size of a file in bytes:
size, err := disk.SizeCtx(ctx, "uploads/video.mp4")
fmt.Printf("File size: %d bytes\n", size)LastModifiedCtx
Get the last modified time:
modTime, err := disk.LastModifiedCtx(ctx, "uploads/document.pdf")
fmt.Printf("Last modified: %s\n", modTime.Format(time.RFC3339))MimeTypeCtx
Get the MIME type of a file:
mimeType, err := disk.MimeTypeCtx(ctx, "uploads/image.jpg")
// Returns: "image/jpeg"Directory Operations
FilesCtx
List files in a directory (non-recursive):
files, err := disk.FilesCtx(ctx, "uploads")
// Returns: ["file1.txt", "file2.pdf"]AllFilesCtx
List all files recursively:
files, err := disk.AllFilesCtx(ctx, "uploads")
// Returns: ["file1.txt", "2024/file2.pdf", "2024/01/file3.jpg"]DirectoriesCtx
List subdirectories (non-recursive):
dirs, err := disk.DirectoriesCtx(ctx, "uploads")
// Returns: ["2024", "temp"]AllDirectoriesCtx
List all subdirectories recursively:
dirs, err := disk.AllDirectoriesCtx(ctx, "uploads")
// Returns: ["2024", "2024/01", "2024/02", "temp"]MakeDirectoryCtx
Create a directory:
err := disk.MakeDirectoryCtx(ctx, "uploads/2024/12")DeleteDirectoryCtx
Delete a directory and all its contents:
err := disk.DeleteDirectoryCtx(ctx, "uploads/temp")URL Operations
URL
Get a permanent public URL for a file. URL is a pure string transformation, so it has no context-aware variant:
// Local disk: returns configured base URL + path
url := disk.URL("public/logo.png")
// S3 disk: returns S3 URL
s3, _ := c.Storage().Disk("s3")
url := s3.URL("images/banner.jpg")
// Returns: "https://my-bucket.s3.amazonaws.com/images/banner.jpg"TemporaryURLCtx
Get a temporary signed URL. This is supported by the S3 driver; the local and memory drivers return storage.ErrNotSupported:
// Generate URL that expires in 15 minutes
url, err := s3.TemporaryURLCtx(ctx, "private/report.pdf", 15*time.Minute)
if err != nil {
if errors.Is(err, storage.ErrNotSupported) {
// Driver cannot mint signed URLs (e.g. local disk)
}
return err
}
// Share the URL - it will expire automatically
fmt.Println("Download link:", url)Working with Multiple Disks
Using Specific Disks
mgr := c.Storage()
s3, err := mgr.Disk("s3")
if err != nil {
return err
}
local, err := mgr.Disk("local")
if err != nil {
return err
}
// Use the disks
s3.PutCtx(ctx, "backups/data.json", jsonData)
local.PutCtx(ctx, "logs/app.log", logData)Disk returns storage.ErrDiskNotFound when the named disk has not been configured.
Copying Between Disks
mgr := c.Storage()
src, err := mgr.Disk("local")
if err != nil {
return err
}
data, err := src.GetCtx(ctx, "uploads/file.pdf")
if err != nil {
return err
}
dst, err := mgr.Disk("s3")
if err != nil {
return err
}
if err := dst.PutCtx(ctx, "backups/file.pdf", data); err != nil {
return err
}Custom Disk Configuration
Build a storage.Config and configure a *storage.Manager directly. Config, DiskConfig, and the driver interface are defined in the storage package (backed by the shared contract leaf):
import (
"context"
"github.com/velocitykode/velocity/storage"
)
func setupCustomStorage(ctx context.Context) (*storage.Manager, error) {
config := storage.Config{
Default: "s3",
Disks: map[string]storage.DiskConfig{
"local": {
Driver: "local",
Root: "./storage/app",
URL: "http://localhost:8090/storage",
},
"s3": {
Driver: "s3",
Key: "YOUR_AWS_KEY",
Secret: "YOUR_AWS_SECRET",
Region: "us-east-1",
Bucket: "my-bucket",
},
"backup": {
Driver: "s3",
Key: "YOUR_AWS_KEY",
Secret: "YOUR_AWS_SECRET",
Region: "us-west-2",
Bucket: "my-backup-bucket",
},
},
}
mgr := storage.NewManager(config)
if err := mgr.ConfigureWithContext(ctx, config); err != nil {
return nil, err
}
return mgr, nil
}github.com/velocitykode/velocity/storage/s3 somewhere in your build so its init() registers the "s3" factory. The local and memory drivers are registered by the storage package itself.Best Practices
1. Use UUIDs for Uploaded Files
Prevent filename collisions:
import "github.com/google/uuid"
func handleFileUpload(ctx context.Context, disk storage.Driver, originalName string, data []byte) error {
// Generate unique filename
ext := filepath.Ext(originalName)
filename := uuid.New().String() + ext
path := fmt.Sprintf("uploads/%s", filename)
return disk.PutCtx(ctx, path, data)
}2. Stream Large Files
Don’t load large files into memory:
// Good: Stream the file
file, _ := os.Open("large-file.mp4")
defer file.Close()
disk.PutStreamCtx(ctx, "videos/video.mp4", file)
// Bad: Load entire file into memory
data, _ := os.ReadFile("large-file.mp4")
disk.PutCtx(ctx, "videos/video.mp4", data)3. Use Appropriate Disks for Sensitive Files
Keep private files on a private disk and mint temporary access for them:
s3, _ := c.Storage().Disk("s3")
// Store a private file
s3.PutCtx(ctx, "invoices/invoice-123.pdf", invoiceData)
// Generate temporary access to a private file
url, _ := s3.TemporaryURLCtx(ctx, "invoices/invoice-123.pdf", 1*time.Hour)4. Handle Errors Properly
import "errors"
func getFile(ctx context.Context, disk storage.Driver, path string) ([]byte, error) {
data, err := disk.GetCtx(ctx, path)
if err != nil {
if errors.Is(err, storage.ErrFileNotFound) {
// Handle missing file specifically
return nil, fmt.Errorf("file not found: %s", path)
}
// Handle other errors
return nil, fmt.Errorf("failed to read file: %w", err)
}
return data, nil
}5. Organize Files with Directory Structure
// Good: Organized structure
disk.PutCtx(ctx, fmt.Sprintf("uploads/%d/%s/avatar.jpg", year, month), data)
disk.PutCtx(ctx, fmt.Sprintf("users/%d/documents/%s.pdf", userID, docType), data)
// Bad: Flat structure
disk.PutCtx(ctx, fmt.Sprintf("avatar-%d.jpg", userID), data)Testing
The github.com/velocitykode/velocity/storage/testing package ships an in-memory fake driver plus fluent assertions, so you can verify storage interactions without touching disk or S3.
Using the Fake Driver
StorageFake() returns a *FakeStorage that records every operation. Use the Fake() builder to synthesize realistic test files:
import (
storageTesting "github.com/velocitykode/velocity/storage/testing"
)
func TestUserAvatar(t *testing.T) {
fake := storageTesting.StorageFake()
// Build a realistic fake image
img := storageTesting.Fake().Image("avatar.jpg", 200, 200)
// Exercise your code against the fake
if err := fake.Put("avatars/user-123.jpg", img.Content()); err != nil {
t.Fatal(err)
}
// Assert against recorded state
a := fake.Assert(t)
a.AssertExists("avatars/user-123.jpg")
a.AssertMissing("avatars/user-456.jpg")
a.AssertCount(1)
}Available assertions include AssertExists, AssertMissing, AssertStored, AssertStoredString, AssertCount, AssertNothingStored, AssertSize, AssertMimeType, AssertDirectory, AssertCopied, AssertMoved, AssertDeleted, AssertContains, AssertURL, and AssertTemporaryURL. You can also drive failure paths with fake.ShouldFail("boom") / fake.ShouldSucceed() and reset state with fake.Clear().
Complete Example
Here’s a complete file upload handler using the request-scoped storage manager:
package handlers
import (
"fmt"
"path/filepath"
"time"
"github.com/google/uuid"
"github.com/velocitykode/velocity/router"
"github.com/velocitykode/velocity/storage"
)
type FileHandler struct{}
// Upload handles file uploads
func (fc *FileHandler) Upload(c *router.Context) error {
disk, err := c.Storage().Default()
if err != nil {
return c.JSON(500, map[string]string{"error": "Storage unavailable"})
}
// Parse multipart form (32 MB max)
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
return c.JSON(400, map[string]string{"error": "Invalid file upload"})
}
// Get the file
file, header, err := c.Request.FormFile("file")
if err != nil {
return c.JSON(400, map[string]string{"error": "No file provided"})
}
defer file.Close()
// Validate file size (10 MB max)
if header.Size > 10*1024*1024 {
return c.JSON(400, map[string]string{"error": "File too large (max 10MB)"})
}
// Generate unique filename
ext := filepath.Ext(header.Filename)
filename := uuid.New().String() + ext
path := fmt.Sprintf("uploads/%s", filename)
// Store the file (streaming)
if err := disk.PutStreamCtx(c.Request.Context(), path, file); err != nil {
return c.JSON(500, map[string]string{"error": "Failed to store file"})
}
return c.JSON(200, map[string]interface{}{
"message": "File uploaded successfully",
"path": path,
"url": disk.URL(path),
"size": header.Size,
})
}
// Download generates a temporary download link
func (fc *FileHandler) Download(c *router.Context) error {
disk, err := c.Storage().Default()
if err != nil {
return c.JSON(500, map[string]string{"error": "Storage unavailable"})
}
filePath := c.Param("path")
if !disk.ExistsCtx(c.Request.Context(), filePath) {
return c.JSON(404, map[string]string{"error": "File not found"})
}
// Generate temporary URL (valid for 1 hour); requires a driver that supports it (S3).
url, err := disk.TemporaryURLCtx(c.Request.Context(), filePath, 1*time.Hour)
if err != nil {
return c.JSON(500, map[string]string{"error": "Failed to generate download link"})
}
return c.JSON(200, map[string]string{
"download_url": url,
"expires_in": "1 hour",
})
}
// Delete removes a file
func (fc *FileHandler) Delete(c *router.Context) error {
disk, err := c.Storage().Default()
if err != nil {
return c.JSON(500, map[string]string{"error": "Storage unavailable"})
}
filePath := c.Param("path")
if !disk.ExistsCtx(c.Request.Context(), filePath) {
return c.JSON(404, map[string]string{"error": "File not found"})
}
if err := disk.DeleteCtx(c.Request.Context(), filePath); err != nil {
return c.JSON(500, map[string]string{"error": "Failed to delete file"})
}
return c.JSON(200, map[string]string{"message": "File deleted successfully"})
}