AWS S3 SDK in production: what it earned its place doing
title: "AWS S3 SDK in production: what it earned its place doing" slug: engineering-quality-dep-aws-sdk-client-s3 pillar: Engineering Quality angle: trade-off audience: CTOs, senior engineers, fellow practitioners status: draft
AWS S3 SDK in production: what it earned its place doing
Why
@aws-sdk/client-s3@^3.1045.0is in this codebase — and why it powers Cloudflare R2, DigitalOcean Spaces, and MinIO alongside actual S3.
The name suggests one thing: files go to Amazon S3. The reality is more useful. The @aws-sdk/client-s3 package in this boilerplate is not a direct AWS integration — it is the foundation of a four-provider storage abstraction that lets you swap your storage backend at runtime from a settings record in the database, without touching a line of application code. That the SDK is named after S3 while being used to talk to R2 and MinIO is either ironic or precisely the point, depending on how you think about protocol lock-in versus vendor lock-in.
The decision and why it cannot be deferred
File storage is one of those infrastructure decisions that looks small on day one and becomes expensive to change on day sixty. A project that hardcodes direct S3 calls across its upload handlers will need to touch every one of those handlers the day a client asks to switch to Cloudflare R2 for egress cost reasons, or when the staging environment needs to run MinIO locally because the team does not have AWS credentials provisioned yet.
This boilerplate makes a specific bet: the S3 API is the common denominator. Cloudflare R2 speaks S3 API. DigitalOcean Spaces speaks S3 API. MinIO speaks S3 API. AWS S3 obviously speaks S3 API. All four provider implementations in modules/storage/providers/ can share the same SDK — @aws-sdk/client-s3 — because the SDK does not validate that the endpoint is actually amazonaws.com. Providing a custom endpoint string routes it to any compatible service.
The provider selection is declared as an enum:
// modules/storage/storage.enums.ts
export const StorageProviderTypeSchema = z.enum([
'aws-s3', 's3', 'cloudflare-r2', 'digitalocean-spaces', 'minio'
])
export type StorageProviderType = z.infer<typeof StorageProviderTypeSchema>
The Zod enum gives you both the TypeScript type and the runtime validator with one declaration — typical of how this boilerplate handles configuration boundaries.
Option A — hardcode S3 directly
The simplest path: import S3Client from @aws-sdk/client-s3 wherever you need it, hardcode the region and credentials from environment variables, and call PutObjectCommand or GetObjectCommand directly in your route handlers or services.
This works until it doesn't. The specific failure modes: you can't run the project locally without AWS credentials, staging environments that use a different bucket require branching configuration logic, and swapping to a different provider requires touching every call site. The dependency on @aws-sdk/client-s3 becomes a semantic coupling to AWS rather than a protocol choice.
// modules/storage/s3.client.ts
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '@/modules/env';
const s3 = new S3Client({
region: env.AWS_REGION || env.AWS_S3_REGION || 'us-east-1',
credentials: {
accessKeyId: env.AWS_ACCESS_KEY_ID || env.AWS_S3_ACCESS_KEY_ID || '',
secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AWS_S3_SECRET_ACCESS_KEY || '',
},
});
export { s3 };
This s3.client.ts file exists in the boilerplate, but it is not what the rest of the application uses for uploads and deletes. It is the raw S3Client instance — the thing that gets wrapped by the provider implementations. The application goes through StorageService, never through s3.client.ts directly.
Option B — provider abstraction backed by the S3 SDK
StorageService is a static class that reads provider configuration from the database at request time and delegates every file operation to the appropriate provider:
// modules/storage/storage.service.ts (excerpt)
private static createProvider(
providerName: StorageProviderType,
config: S3Config
): BaseStorageProvider {
switch (providerName) {
case 'aws-s3':
return new AWSS3Provider(config)
case 'cloudflare-r2':
return new CloudflareR2Provider(config)
case 'digitalocean-spaces':
return new DigitalOceanSpacesProvider(config)
case 'minio':
return new MinIOProvider(config)
default:
Logger.error(`${STORAGE_MESSAGES.PROVIDER_NOT_FOUND}: ${providerName}`)
throw new Error(`${STORAGE_MESSAGES.PROVIDER_NOT_FOUND}: ${providerName}`)
}
}
Each Provider implementation receives the same S3Config shape — bucket, region, accessKeyId, secretAccessKey, and an optional endpoint override. The endpoint override is what makes R2 and DigitalOcean Spaces possible: both services expose S3-compatible APIs at custom endpoints, and the AWS SDK routes correctly when you override the endpoint URL.
The cost paid for this abstraction: every storage operation involves a SettingService.getByKeys() call to read the current provider configuration from the database. The config is not cached in memory, which means it can change at runtime without a restart — useful when a client needs to migrate storage providers on a live system, but it adds a database round-trip to every file operation.
Option C — a third-party abstraction library
Libraries like multer-s3 or @vercel/blob handle storage abstractions at a higher level. They tend to be simpler to set up for a single provider but harder to extend when you need to add a fourth backend type that is not on their supported list. multer-s3 is specifically tied to Express's multer middleware, which makes it awkward if you want to upload from a URL rather than from a multipart form.
The boilerplate took a different path: build a thin abstraction on top of the AWS SDK directly. The BaseStorageProvider interface defines the operations (upload, delete, getFileUrl), and each provider implements them. This is more code to maintain, but it means MinIO support is not contingent on a third-party library shipping it.
The deciding factor in this repo and the artifact that justifies it
The DB-driven configuration is the deciding factor. Configuration comes from SettingService — stored in the database, editable via the settings API — rather than hardcoded environment variables:
// modules/storage/storage.service.ts
private static async getStorageSettings(): Promise<{
providerName: StorageProviderType;
config: S3Config;
}> {
const settings = await SettingService.getByKeys([...STORAGE_KEYS])
const providerName = (settings.storageProvider || 'aws-s3') as StorageProviderType
const config: S3Config = {
bucket: settings.s3Bucket || '',
region: settings.s3Region || 'us-east-1',
accessKeyId: settings.s3AccessKey || '',
secretAccessKey: settings.s3SecretKey || '',
endpoint: settings.s3Endpoint || undefined,
}
return { providerName, config }
}
The endpoint field is the linchpin. For Cloudflare R2, it points to https://<account-id>.r2.cloudflarestorage.com. For DigitalOcean Spaces, it points to the regional endpoint. For MinIO, it points to whatever host your local or self-hosted instance runs on. For actual AWS S3, it is left undefined and the SDK resolves the endpoint from the region automatically.
The full upload path assembles dynamically at request time:
// modules/storage/storage.service.ts
static async uploadFile(data: UploadFileDTO): Promise<UploadResult> {
const { file, folder, filename, provider: requestedProvider, tenantId = 'system' } = data
try {
const { provider, resolvedName } = await StorageService.getProvider(requestedProvider)
const result = await provider.uploadFile(file, { folder, filename, tenantId })
return {
...result,
provider: resolvedName,
}
} catch (error) {
Logger.error(
`${STORAGE_MESSAGES.UPLOAD_FAILED}: ${error instanceof Error ? error.message : String(error)}`
)
throw error
}
}
The requestedProvider parameter allows a caller to explicitly override the default — for example, to force a specific upload to MinIO during a migration window even when the default setting is R2. The returned UploadResult includes provider: resolvedName, which means downstream code and audit logs know exactly which provider handled the file.
CTA
The question that reverses the decision: how stable is your client's storage choice? If they have an AWS account, a preferred region, and no plans to change, the abstraction layer adds complexity for no immediate benefit. The overhead makes sense when any of these are true: the team needs to develop locally without cloud credentials, the project might run on a self-hosted or on-premise deployment with MinIO, or the client is cost-sensitive and Cloudflare R2's zero-egress pricing matters.
If you're building a boilerplate that will be deployed in multiple configurations, the provider abstraction pays for itself in the first production migration. If you're building one project for one client with one storage configuration, it is probably overengineering.
Trade-off
The provider abstraction reads configuration from the database on every storage operation. This is a deliberate design choice that makes runtime provider switching possible at the cost of an extra DB query per upload, delete, and URL resolution. For high-throughput upload flows, that query becomes a latency contributor. The fix — caching the provider config in memory with a short TTL — is straightforward but not implemented in the current version, which means the decision to add caching gets pushed to whoever deploys this.
The other trade-off is the S3 API surface constraint. Any storage feature that is not part of the S3 protocol — R2's automatic image transformations, S3's Object Lambda, GCS-specific capabilities — is unreachable through this abstraction. Providers that extend S3 with proprietary features either need a provider-specific escape hatch or those features go unused.
Business impact
Storage provider portability has direct cost implications in client projects. Cloudflare R2's zero-egress pricing can represent 30-60% cost savings compared to S3 for image-heavy applications where users are downloading files regularly. The ability to migrate from S3 to R2 without touching application code makes that migration a configuration change rather than a development engagement. For multi-tenant applications where different tenants might have data sovereignty requirements mandating different storage regions or providers, the DB-driven per-request configuration enables a compliance feature that would otherwise require separate deployments.
What to do next
If you inherit a project that hardcodes S3 calls at every upload point, count the number of call sites before deciding whether to abstract. Three call sites probably don't justify building a provider abstraction. Thirty do. The boilerplate's StorageService pattern is directly extractable — BaseStorageProvider, the S3Config type, and the createProvider switch — if you decide the abstraction is worth it for your project's deployment profile.
Check whether your project's storage configuration is in environment variables or in the database. Environment variables require a restart to change; database settings change at runtime. That difference matters for clients who want to configure storage through an admin panel rather than asking an engineer to update .env files.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox