S3 Redirect
File downloads as short-lived signed redirects to your bucket, after PocketBase's own access check.
When an app keeps its files in S3 (Settings → Files storage), PocketBase normally downloads each file from the bucket
and streams it to the browser. With S3 Redirect, /api/files/... answers 302 with a presigned GET URL instead,
and the browser fetches the file straight from the bucket. The app's bandwidth, memory and connections stay free.
With Cloudflare R2 the downloads cost no egress either.
Install · Configure · Use · Integrations · Example
Install
S3 Redirect is built into pocketone and on by default. It only acts when S3 file storage is on: turn on S3 in
Settings → Files storage and downloads redirect. With files on local disk, nothing changes.
To stream files through the app as before:
pocketone serve --s3Redirect=false
In your own Go build, register it before the app starts:
import "github.com/pockethost/pocketone/ext/s3redirect"
app := pocketbase.New()
s3redirect.MustRegister(app, s3redirect.Config{TTL: 5 * time.Minute})
app.Start()
Then turn on S3 in Settings → Files storage.
Configure
Set it in pocketone.yaml:
s3redirect:
enabled: true # false streams files through the app
ttl: 5m # how long each signed URL is valid
Or with flags, which win over the file:
| Flag | Default | What it does |
|---|---|---|
--s3Redirect |
true |
Redirect downloads when S3 file storage is on. --s3Redirect=false streams them. |
--s3RedirectTTL |
5m |
How long each signed URL stays valid (at most 7 days). |
Keep the TTL short: anyone holding a URL can fetch the file until it expires, protected files included.
The bucket itself is PocketBase's own S3 setting, in Settings → Files storage. There are no other settings screens.
Bucket CORS. A redirect followed by fetch() or <img crossorigin> from your site needs a CORS rule on the
bucket allowing GET from your origin. Plain <img>, <video> and links need nothing.
pocketone.yaml reference
s3redirect:
Signed S3 redirects for file downloads: enabled, URL lifetime. The --s3Redirect and --s3RedirectTTL flags win.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool | On unless false (it only acts when S3 file storage is on). Flag: --s3Redirect. | |
ttl |
duration | How long each signed URL stays valid. Default 5m, max 7 days. Flag: --s3RedirectTTL. |
Use
Once S3 is on, there's nothing to click. File URLs stay the same; they answer with a redirect:
- Access checks don't change. PocketBase still finds the record, checks protected-file tokens and the view rule, and creates thumbnails on first request. The redirect happens only after all of that.
- The file token stays private. It never goes into the bucket URL, and the redirect sends
Referrer-Policy: no-referrer. - Same headers as PocketBase: inline or attachment (
?download=1), the filename, and forced content types (SVG, CSS, JS) are signed into the URL as S3response-*overrides. Range requests work, because S3 serves them. - Caching: the browser may reuse the redirect for 80% of the TTL (
Cache-Control: private, never shared caches); the file itself gets PocketBase's usual 30-dayCache-Control. - Falls back to streaming when S3 is off, the extension is turned off, or signing fails. A failed signature logs a
warning,
s3redirect: signing failed, streaming instead. - Only
GETandHEADrequests are redirected.
Inline vs attachment: PocketBase decides this from the stored content type; S3 Redirect goes by the file extension (it doesn't ask the bucket, so a redirect costs no extra round trip). They agree for every file PocketBase names.
Try it
cd example && go run . serve
The example runs one app with its own in-process bucket, nothing to set up. It:
- starts an S3-compatible bucket on
127.0.0.1:9199(gofakes3, objects kept inpb_data/demo-s3.bolt), and points the app's Files storage at it; - seeds a
demo_filesrecord with an image (thumbs100x100) and a protected text file, uploaded through PocketBase; - prints the URLs to open. Each answers
302to the bucket.
The bucket only serves presigned GETs whose signature, re-computed with the AWS SDK's own SigV4 signer, matches
and hasn't expired, and logs each one (demo bucket: presigned GET ..., signature valid). Unsigned or edited URLs get
403. So a working download proves the signatures are AWS-compatible, not just accepted by a lenient fake.
S3REDIRECT_DEMO=0 skips the demo bucket (use your own Files storage settings); S3REDIRECT_DEMO_ADDR moves it.
The example is its own Go module, so its demo dependencies (gofakes3, the AWS SDK signer) never reach the extension's
go.mod.
Integrations
Works with
| Extension | How they fit |
|---|---|
| Pocker | The same extension runs on every instance. The s3redirect section sets the defaults; each instance can override enabled and ttl (below). |
| S3 Fallback | Apps without their own S3 settings keep files in its bucket (under pocker, fenced to instances/<id>/storage/). Its driver signs, so those downloads redirect too. |
Per-instance settings (pocker)
srv.Extensions = append(srv.Extensions, s3redirect.New(s3redirect.Config{}))
s3redirect: # server defaults
enabled: true # on unless false
ttl: 5m
instances:
- id: abc
extensions:
s3redirect: { enabled: false } # per instance: enabled, ttl
It's an extkit extension, so the same value works on a single app and on every pocker instance; it doesn't link pocker.
Building on S3 Redirect
- Supply your own bucket. S3 Redirect signs for whatever filesystem PocketBase serves the download from: the
app's own S3 settings, or one an extension supplies through
OnFilesystemCreate. Implements3redirect.Signer(SignedURL(key, ttl, now, overrides)) on your filesystem driver and its downloads redirect. Nothing else needs wiring; a filesystem that can't sign (local disk) streams as usual. - Your own download handlers run first. The extension binds
OnFileDownloadRequestlast (priority 1000, ids3redirect.HookID,__ext_s3redirect__), so your Go or JS handlers can still change the served file or answer themselves without callinge.Next(). Unbind the id to turn it off for one app.
Example
example/main.go starts its own in-process S3 bucket, points the app's file storage at it and seeds a record, so every download redirect works with nothing to set up. Run go run . serve in example/ and open the URLs it prints.
// Command example runs one PocketOne app whose file downloads redirect to an S3 bucket, with nothing to set up:
// it starts its own S3-compatible bucket in-process (gofakes3, stored in pb_data/demo-s3.bolt), points the app's
// Files storage at it, and seeds a "demo_files" record with an image and a protected document.
//
// go run . serve
//
// Open the URLs it prints. Each answers 302 with a signed URL; the bucket logs every fetch and only serves it if the
// signature is valid and unexpired, checked independently with the AWS SDK's SigV4 signer.
//
// Set S3REDIRECT_DEMO=0 to skip the demo bucket and use whatever Files storage you configure in the dashboard.
//
// Logins (ext/devusers, local testing only): dashboard [email protected] / password, app user [email protected] / password.
package main
import (
"log"
"os"
"time"
"ganigeorgiev.com/pocketbase/pocketbase"
"github.com/pockethost/pocketone/ext/devusers"
"github.com/pockethost/pocketone/ext/s3redirect"
)
func main() {
app := pocketbase.New()
devusers.Register(app) // local testing: superuser [email protected] and user [email protected], password "password"
s3redirect.MustRegister(app, s3redirect.Config{TTL: 5 * time.Minute})
if os.Getenv("S3REDIRECT_DEMO") != "0" {
addr := os.Getenv("S3REDIRECT_DEMO_ADDR")
if addr == "" {
addr = "127.0.0.1:9199"
}
registerDemo(app, addr)
}
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
How it works
- Go module
github.com/pockethost/pocketone/ext/s3redirect, no dependencies beyond PocketOne. - The redirect is an
OnFileDownloadRequesthandler; the hook already exists upstream, so there's no core change. - Signing is SigV4 query-string auth in
sigv4, checked against AWS's documented example. It needs no change to PocketBase's S3 client. The TTL is clamped to between 1 second and 7 days.
License
MIT
Questions and comments
Comments are a GitHub Discussions thread; sign in with GitHub to post.