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 S3 response-* 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-day Cache-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 GET and HEAD requests 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 in pb_data/demo-s3.bolt), and points the app's Files storage at it;
  • seeds a demo_files record with an image (thumbs 100x100) and a protected text file, uploaded through PocketBase;
  • prints the URLs to open. Each answers 302 to 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. Implement s3redirect.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 OnFileDownloadRequest last (priority 1000, id s3redirect.HookID, __ext_s3redirect__), so your Go or JS handlers can still change the served file or answer themselves without calling e.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 OnFileDownloadRequest handler; 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