Skip to main content
PocketBase supports S3-compatible storage backends as an alternative to local filesystem storage. This includes AWS S3, MinIO, DigitalOcean Spaces, Cloudflare R2, and other S3-compatible services.

Why use S3 storage

S3-compatible storage provides several advantages:
  • Scalability - Handle unlimited file storage without disk space concerns
  • Reliability - Built-in redundancy and durability (99.999999999% for AWS S3)
  • Performance - Geographic distribution and CDN integration
  • Cost efficiency - Pay only for what you use, with automatic scaling
  • Backups - Easy replication across regions

Storage backend selection

PocketBase automatically selects the storage backend based on your settings:
// From core/base.go:714
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
    if app.settings != nil && app.settings.S3.Enabled {
        return filesystem.NewS3(
            app.settings.S3.Bucket,
            app.settings.S3.Region,
            app.settings.S3.Endpoint,
            app.settings.S3.AccessKey,
            app.settings.S3.Secret,
            app.settings.S3.ForcePathStyle,
        )
    }
    
    // Fallback to local filesystem
    return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
}

Configuration via Admin UI

The easiest way to configure S3 storage is through the PocketBase Admin UI:
1

Navigate to Settings

Go to Settings > Files storage in the Admin UI
2

Enable S3 storage

Toggle “Use S3 storage” to enable it
3

Enter S3 credentials

Fill in your S3 bucket configuration:
  • Bucket name
  • Region
  • Endpoint
  • Access key
  • Secret key
  • Force path style (if required)
4

Test connection

Use the “Test” button to verify your configuration
5

Save settings

Click “Save” to apply the configuration
Changing from local to S3 storage (or vice versa) does not automatically migrate existing files. You’ll need to manually copy files between storage backends.

Configuration via code

You can also configure S3 storage programmatically:
package main

import (
    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/core"
)

func main() {
    app := pocketbase.New()
    
    app.OnServe().BindFunc(func(e *core.ServeEvent) error {
        // Update S3 settings
        settings, err := e.App.Settings().Clone()
        if err != nil {
            return err
        }
        
        settings.S3.Enabled = true
        settings.S3.Bucket = "my-bucket"
        settings.S3.Region = "us-east-1"
        settings.S3.Endpoint = "https://s3.amazonaws.com"
        settings.S3.AccessKey = "YOUR_ACCESS_KEY"
        settings.S3.Secret = "YOUR_SECRET_KEY"
        settings.S3.ForcePathStyle = false
        
        if err := e.App.Settings().Merge(settings); err != nil {
            return err
        }
        
        return e.Next()
    })
    
    if err := app.Start(); err != nil {
        panic(err)
    }
}

Environment variables

For production deployments, use environment variables to avoid hardcoding credentials:
export S3_ENABLED=true
export S3_BUCKET=my-bucket
export S3_REGION=us-east-1
export S3_ENDPOINT=https://s3.amazonaws.com
export S3_ACCESS_KEY=YOUR_ACCESS_KEY
export S3_SECRET_KEY=YOUR_SECRET_KEY
export S3_FORCE_PATH_STYLE=false
Then read them in your code:
import "os"

settings.S3.Enabled = os.Getenv("S3_ENABLED") == "true"
settings.S3.Bucket = os.Getenv("S3_BUCKET")
settings.S3.Region = os.Getenv("S3_REGION")
settings.S3.Endpoint = os.Getenv("S3_ENDPOINT")
settings.S3.AccessKey = os.Getenv("S3_ACCESS_KEY")
settings.S3.Secret = os.Getenv("S3_SECRET_KEY")
settings.S3.ForcePathStyle = os.Getenv("S3_FORCE_PATH_STYLE") == "true"

Provider-specific configurations

AWS S3

settings.S3.Enabled = true
settings.S3.Bucket = "my-app-files"
settings.S3.Region = "us-east-1"
settings.S3.Endpoint = "https://s3.amazonaws.com"
settings.S3.AccessKey = "AKIA..."
settings.S3.Secret = "your-secret-key"
settings.S3.ForcePathStyle = false
For AWS S3, you can omit the endpoint - it will be constructed automatically from the region: https://s3.{region}.amazonaws.com

MinIO

settings.S3.Enabled = true
settings.S3.Bucket = "pocketbase"
settings.S3.Region = "us-east-1" // MinIO region (can be any value)
settings.S3.Endpoint = "https://minio.example.com"
settings.S3.AccessKey = "minioadmin"
settings.S3.Secret = "minioadmin"
settings.S3.ForcePathStyle = true // MinIO requires path-style URLs

DigitalOcean Spaces

settings.S3.Enabled = true
settings.S3.Bucket = "my-space"
settings.S3.Region = "nyc3"
settings.S3.Endpoint = "https://nyc3.digitaloceanspaces.com"
settings.S3.AccessKey = "DO00..."
settings.S3.Secret = "your-secret-key"
settings.S3.ForcePathStyle = false

Cloudflare R2

settings.S3.Enabled = true
settings.S3.Bucket = "my-bucket"
settings.S3.Region = "auto"
settings.S3.Endpoint = "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com"
settings.S3.AccessKey = "your-access-key"
settings.S3.Secret = "your-secret-key"
settings.S3.ForcePathStyle = false

Backblaze B2

settings.S3.Enabled = true
settings.S3.Bucket = "my-bucket"
settings.S3.Region = "us-west-002"
settings.S3.Endpoint = "https://s3.us-west-002.backblazeb2.com"
settings.S3.AccessKey = "your-key-id"
settings.S3.Secret = "your-application-key"
settings.S3.ForcePathStyle = false

Path-style vs. virtual-hosted-style URLs

S3 supports two URL styles:

Virtual-hosted-style (default)

https://bucket-name.s3.amazonaws.com/path/to/file.txt
Used by AWS S3, DigitalOcean Spaces, and most modern providers. Set ForcePathStyle: false (or omit, as it’s the default).

Path-style

https://s3.amazonaws.com/bucket-name/path/to/file.txt
Required by MinIO and some self-hosted S3 implementations. Set ForcePathStyle: true.
AWS S3 deprecated path-style URLs for buckets created after September 30, 2020. Use virtual-hosted-style for AWS S3.

Direct S3 filesystem creation

For advanced use cases, you can create S3 filesystem instances directly:
import (
    "github.com/pocketbase/pocketbase/tools/filesystem"
)

func createS3Filesystem() (*filesystem.System, error) {
    fsys, err := filesystem.NewS3(
        "my-bucket",              // bucket
        "us-east-1",              // region
        "https://s3.amazonaws.com", // endpoint
        "AKIA...",                // access key
        "your-secret-key",        // secret key
        false,                    // force path style
    )
    if err != nil {
        return nil, err
    }
    
    return fsys, nil
}

Using the S3 filesystem

fsys, err := createS3Filesystem()
if err != nil {
    return err
}
defer fsys.Close()

// Upload file
file, _ := filesystem.NewFileFromPath("/path/to/file.txt")
err = fsys.UploadFile(file, "uploads/file.txt")

// Download file
reader, err := fsys.GetReader("uploads/file.txt")
if err != nil {
    return err
}
defer reader.Close()

// List files
files, err := fsys.List("uploads/")
for _, file := range files {
    fmt.Println(file.Key, file.Size, file.ModTime)
}

// Delete file
err = fsys.Delete("uploads/file.txt")

// Copy file
err = fsys.Copy("uploads/file.txt", "backups/file.txt")

S3 implementation details

PocketBase uses a custom S3 implementation (not AWS SDK) for minimal dependencies:
// From tools/filesystem/filesystem.go:44
func NewS3(
    bucketName string,
    region string,
    endpoint string,
    accessKey string,
    secretKey string,
    s3ForcePathStyle bool,
) (*System, error) {
    ctx := context.Background()
    
    client := &s3.S3{
        Bucket:       bucketName,
        Region:       region,
        Endpoint:     endpoint,
        AccessKey:    accessKey,
        SecretKey:    secretKey,
        UsePathStyle: s3ForcePathStyle,
    }
    
    drv, err := s3blob.New(client)
    if err != nil {
        return nil, err
    }
    
    return &System{ctx: ctx, bucket: blob.NewBucket(drv)}, nil
}
The internal S3 client supports:
  • ✅ GetObject - Download files
  • ✅ HeadObject - Get file metadata
  • ✅ PutObject - Upload files
  • ✅ DeleteObject - Delete files
  • ✅ CopyObject - Copy files
  • ✅ ListObjects - List files with pagination
  • ✅ Multipart uploads - For large files
  • ✅ AWS Signature V4 - Authentication

Bucket permissions

Your S3 bucket needs the following permissions:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:ListBucket",
        "s3:GetObjectAttributes"
      ],
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ]
    }
  ]
}
Never make your bucket publicly readable! PocketBase handles all file access through its API, respecting collection rules and file field protection settings.

CORS configuration

If you’re accessing files directly from the browser, configure CORS on your bucket:
[
  {
    "AllowedOrigins": ["https://your-app.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]
CORS is only needed if you’re serving files directly from S3. When using PocketBase’s API endpoint, CORS is handled by PocketBase.

Migration between storage backends

To migrate files from local to S3 (or vice versa):
func migrateStorage(app core.App) error {
    // Create source filesystem (local)
    src, err := filesystem.NewLocal("./pb_data/storage")
    if err != nil {
        return err
    }
    defer src.Close()
    
    // Create destination filesystem (S3)
    dst, err := filesystem.NewS3(
        "my-bucket",
        "us-east-1",
        "https://s3.amazonaws.com",
        "access-key",
        "secret-key",
        false,
    )
    if err != nil {
        return err
    }
    defer dst.Close()
    
    // List all files
    files, err := src.List("")
    if err != nil {
        return err
    }
    
    // Copy each file
    for _, file := range files {
        if file.IsDir {
            continue
        }
        
        // Read from source
        reader, err := src.GetReader(file.Key)
        if err != nil {
            return err
        }
        
        // Read all data
        data, err := io.ReadAll(reader)
        reader.Close()
        if err != nil {
            return err
        }
        
        // Upload to destination
        err = dst.Upload(data, file.Key)
        if err != nil {
            return err
        }
        
        fmt.Printf("Migrated: %s\n", file.Key)
    }
    
    return nil
}

Performance considerations

Multipart uploads

For files larger than 5MB, PocketBase automatically uses multipart uploads:
// Configure multipart upload settings
writerOpts := &blob.WriterOptions{
    BufferSize:     10 << 20,  // 10MB part size
    MaxConcurrency: 5,          // 5 concurrent uploads
}

Concurrent operations

The S3 driver supports concurrent operations:
// Concurrent file uploads
var wg sync.WaitGroup
for _, file := range files {
    wg.Add(1)
    go func(f *filesystem.File) {
        defer wg.Done()
        fsys.UploadFile(f, f.Name)
    }(file)
}
wg.Wait()

Caching

PocketBase sets aggressive cache headers for S3 files:
Cache-Control: max-age=2592000, stale-while-revalidate=86400
This caches files for 30 days while allowing background revalidation.

Troubleshooting

Connection errors

failed to initialize S3: endpoint must be set
Ensure the endpoint URL is set correctly and includes the protocol (https://).

Authentication errors

status code 403: Access Denied
Verify your access key and secret key. Check that your IAM user/role has the required permissions.

Path-style errors

status code 404: NoSuchBucket
Try toggling ForcePathStyle. MinIO requires true, AWS S3 requires false.

Region errors

AuthorizationHeaderMalformed: The authorization header is malformed
Ensure the region matches your bucket’s region exactly.

Next steps

File upload

Learn how to upload files to S3 storage

File download

Understand how files are served from S3