Skip to main content

Storage

routed_storage owns storage configuration and publishes a StorageManager. Disk definitions are typed Dart values:

import 'package:routed/routed.dart';

final storage = StorageConfig(
defaultDisk: 'local',
disks: {
'local': const LocalStorageDiskConfig(root: 'storage/app'),
'assets': const LocalStorageDiskConfig(root: 'public'),
'backups': const LocalStorageDiskConfig(root: 'storage/backups'),
},
);

final engine = await Engine.create(
providers: [
...Engine.defaultProviders,
RoutedStorageProvider(configuration: storage),
],
);

engine.get('/files/:path', (ctx) async {
final storage = ctx.storage();
final path = ctx.param('path')!;
return ctx.json({
'exists': await storage.exists(path),
'contents': await storage.get(path),
});
});

StorageConfig creates local disks. For S3-compatible object storage, build an S3StorageDisk, register it with an application-owned manager, and supply that manager to RoutedStorageProvider:

import 'dart:io';

import 'package:routed/routed.dart';

final environment = Platform.environment;
final s3 = S3StorageDisk(
endpoint: 'https://<account-id>.r2.cloudflarestorage.com',
accessKey: environment['S3_ACCESS_KEY']!,
secretKey: environment['S3_SECRET_KEY']!,
bucket: 'uploads',
region: 'auto',
pathStyle: true,
prefix: 'production',
);

final manager = StorageManager()
..registerDisk('uploads', s3)
..setDefault('uploads');

final engine = await Engine.create(
providers: [
...Engine.defaultProviders,
RoutedStorageProvider(manager: manager),
],
);

AWS S3, Cloudflare R2, DigitalOcean Spaces, and MinIO use the same driver. A bare endpoint host defaults to HTTPS, and cleartext HTTP is rejected by default. Local MinIO development can explicitly set allowInsecureHttp: true with an HTTP endpoint; do not enable it across an untrusted network. The constructor does not contact the service or create the bucket.

Using the manager

final manager = await engine.make<StorageManager>();
final logo = manager.resolve('logo.png', disk: 'assets');

Use ctx.storage() or manager.storage() for the storage_fs filesystem API: put, get, readStream, exists, delete, listings, and metadata all work without casting the selected disk. Use ctx.temporaryStorageUrl() or manager.temporaryUrl() for provider-signed URLs. Provider SDKs and cloud adapters remain internal to server_storage; the manager keeps this API scoped to the Routed application instead of initializing storage_fs's process-global Storage facade.

final url = await ctx.temporaryStorageUrl(
'reports/monthly.pdf',
DateTime.now().add(const Duration(minutes: 5)),
disk: 'assets',
);

Object keys passed to these operations remain rooted inside the disk's configured prefix.

Native Cloudflare R2 bindings

On Cloudflare Workers, prefer the runtime's native R2 binding when the object store is already declared in Wrangler. routed_node adapts that binding to storage_fs, so request handlers retain the same storage API without S3 credentials:

Future<Engine> createCloudflareEngine(
CloudflareEnvironment environment,
) async {
final r2 = CloudflareR2Filesystem(
bucket: environment.r2('FILES'),
prefix: 'production',
);
final manager = StorageManager()
..registerFilesystem('r2', r2)
..setDefault('r2');
final signer = StorageSignedUrlSigner(
cloudflareTextBinding(environment, 'STORAGE_SIGNING_KEY'),
);
final engine = Engine(
providers: [
...Engine.defaultProviders,
RoutedStorageProvider(manager: manager),
],
);

engine.signedStorage(
'/downloads',
r2,
signer: signer,
rootPath: 'private',
);

engine.get('/downloads/report-url', (ctx) async {
// Authenticate the user and authorize this exact object first.
final expiresAt = DateTime.now().add(const Duration(minutes: 5));
return ctx.json({
'url': signer.sign(
Uri.parse('https://app.example.com/downloads/report.pdf'),
expiresAt: expiresAt,
).toString(),
});
});

await engine.initialize();
return engine;
}

Deploy with the environment-aware factory and matching binding:

routed deploy --target cloudflare \
--cloudflare-factory environment \
--r2 FILES=app-files

npx wrangler secret put STORAGE_SIGNING_KEY --name YOUR_WORKER_NAME

This uses Cloudflare's injected R2Bucket; no account ID or S3 credential is needed, and the secret is read through Routed's host-neutral binding API with no direct dart:io or JavaScript interop. R2 has no per-object public ACL in the Worker binding, so CloudflareR2Filesystem always reports private visibility and rejects visibility: 'public'. signedStorage() verifies the path, expiration, and signature before reading the object. The URL-issuing route must perform the application's authentication and object authorization. For other hosts, or when the same bucket must be accessed outside a Worker, use S3StorageDisk with the R2 S3-compatible endpoint instead.

SFTP disks

SftpStorageDisk uses the file_sftp transport and participates in the same manager:

final environment = Platform.environment;
final sftp = SftpStorageDisk(
config: SftpConfig(
host: environment['SFTP_HOST']!,
port: int.tryParse(environment['SFTP_PORT'] ?? '') ?? 22,
username: environment['SFTP_USERNAME']!,
password: environment['SFTP_PASSWORD'],
root: '/srv/uploads',
),
);

manager.registerDisk('archive', sftp);
await manager.storage('archive').put('reports/daily.json', reportJson);

Use privateKeyPems and privateKeyPassphrase instead of password for key authentication. Connections open lazily. Call await sftp.close() during application shutdown to release both the storage and package:file sessions.

The same manager can be exposed to request handlers with storageMiddleware(manager). Configuration is validated before provider boot; an unknown default disk or empty disk root fails startup.

See Static Assets for declarative mounts and Views & Templates for template storage.