Skip to content

Companion to README.md (full design), poc-alpha-setup.md (alpha bootstrap), and product-naming.md (public product name history).

StatusShipped — upload/confirm, private presign, module gating in directwerk-digital
Operator how-to../Directwerk/docs/media-upload-howto.md

This document specifies how assets are stored, scoped, and served using S3-compatible object storage in Europe — with Hetzner Object Storage as the primary target and Bunny.net Storage as an alternative.

Quick reference

text
Bucket: directwerk-{env}   (or dual: directwerk-{env}-public / -private)
Key:    {tenant_slug}/{public|private|staging}/{type}/{uuid}_{sanitizedFilenameStem}.{ext}

Public  → CDN URL (immutable, cacheable)
Private → ModuleGate → AssetAccessService → presignGet (1h API / 24h RSS)
Upload  → presignPut to staging → confirm → promote to the final prefix

Never: prefix-wide credentials, ListObjects for clients, logging presigned URLs

Core rule: Private bytes are never world-readable. Access to one private asset does not grant access to any other private asset — enforcement is per asset, via AssetAccessService, not via bucket policies or user-prefix browsing.


Goals

GoalHow we achieve it
Tenant isolationEvery object key starts with {tenant_slug}/; DB + app validate tenant_id
Public vs privateSeparate key prefixes (public/ vs private/); different URL strategies
Per-asset entitlementsEntitlementService.hasAccess(userId, contentId) before every private signed URL
No “one unlocks all”Never issue prefix-scoped credentials; never sign URLs without asset-level check
EU data residencyHetzner DE/FI or Bunny EU regions only
No API upload proxyPre-signed PUT for uploads; pre-signed GET for private downloads
CDN for publicStable URLs on public pull zone; cacheable public/ objects; edge-block private/
CDN for privateSecond pull zone + Advanced Token Auth; see bunny-net-integration.md

Provider choice (Europe)

Both providers expose an S3-compatible API compatible with AWS SDK v2 for Java (software.amazon.awssdk:s3, S3Presigner).

AspectDetail
Regions (EU)Falkenstein fsn1, Nuremberg nbg1 (Germany); Helsinki hel1 (Finland)
Endpointhttps://{location}.your-objectstorage.com (e.g. https://fsn1.your-objectstorage.com)
DNS-stylehttps://{bucket}.{location}.your-objectstorage.com
GDPRGerman operator; DPA available
FitSame vendor as Coolify/Hetzner Cloud deployment; simple pricing (ingress + API free)
CDNPair with Hetzner CDN or Cloudflare in front of public/

Recommendation: Primary region nbg1 (Nuremberg) or fsn1 (Falkenstein) for German data residency; use hel1 only if Finland is acceptable for a tenant’s DPA.

Alternative: Bunny.net Storage (S3-compatible)

AspectDetail
EU regionsFrankfurt de, London uk, Stockholm se
Endpointhttps://{region}-s3.storage.bunnycdn.com (path-style only)
CredentialsAccess Key = storage zone name; Secret = zone password
S3 modeMust enable S3 compatibility at zone creation (cannot enable later)
CDNBuilt-in Bunny CDN pull zones — strong fit if public media is CDN-heavy
CaveatS3 API is public preview; path-style + SigV4 required (forcePathStyle=true)

Recommendation: Choose Bunny when integrated CDN + storage in one product outweighs Hetzner stack simplicity. For API-only origin with separate CDN, Hetzner is simpler.

Dev / local / CI

No local object-storage emulator. All environments — including developer laptops — use a dedicated dev bucket on Hetzner Object Storage or Bunny.net Storage (EU region). Same key layout as staging and production; only bucket name and credentials differ.

EnvironmentStorage
Local devHetzner or Bunny dev bucket (directwerk-dev) — credentials in .env.local (never commit)
CI unit testsMock S3Client / S3Presigner — no network calls
CI integration (optional)Nightly job against dev bucket with CI secrets
Staging / prodHetzner (default) or Bunny per deployment

Local infra is Directwerk/compose.yaml (PostgreSQL + Mailpit) — see ../Directwerk/docs/build-and-deploy.md. S3 is not in Compose; configure a Hetzner or Bunny dev bucket via env vars (S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY) when implementing uploads.

Create the dev bucket once in Hetzner Console or Bunny dashboard; enable S3 compatibility on Bunny at zone creation if using Bunny.

Decision matrix

CriterionHetzner Object StorageBunny.net Storage
EU regionsDE (×2), FIDE, UK, SE
S3 maturityProductionPublic preview
Path-styleBothPath-style only
Built-in CDNSeparate productIntegrated
Coolify alignmentHigh (same ecosystem)Medium
Default for DirectwerkYesOptional per deployment

Record the chosen provider per environment in deployment config — the application layer stays provider-agnostic.


Bucket strategy

One bucket (e.g. directwerk-prod) with tenant and visibility prefixes:

{bucket}/
  {tenant_slug}/
    public/          # world-readable via CDN
    private/         # never directly listable; signed GET only
    staging/         # upload scratch; purged app-side after staging-lifecycle-hours
    user/            # optional per-user private subtree (see below)

Why not separate physical buckets per tenant? Operational overhead at scale. Prefix isolation

  • application guards + optional IAM condition keys are sufficient for MVP.

Optional: dual bucket per environment

BucketContentsBucket policy
directwerk-{env}-publicAll {tenant}/public/**Public read (or CDN origin only)
directwerk-{env}-privateAll {tenant}/private/**, staging/**, user/**Deny all public access; app credentials only

Use dual buckets when compliance requires a hard network boundary between public and private objects. The key layout inside each bucket stays identical; S3StorageProperties holds two bucket names.


Key layout and asset scopes

Storage is split on three axes:

  1. Tenant{tenant_slug}/ root prefix (mandatory)
  2. Visibilitypublic/ vs private/ vs staging/
  3. Scope — what entitlement check applies before signing a URL

Scope types (AssetScope)

ScopeKey patternEntitlement check
TENANT_PUBLIC{tenant}/public/{type}/{uuid}_{sanitizedFilenameStem}.{ext}None — CDN URL
CONTENT{tenant}/private/{type}/{uuid}_{sanitizedFilenameStem}.{ext}EntitlementService.hasAccess(userId, episodeId | publicationId)
USER{tenant}/private/user/{user_id}/{uuid}_{sanitizedFilenameStem}.{ext}principal.userId == asset.ownerUserId plus any content link
SYSTEM{tenant}/private/system/{purpose}/{uuid}_{sanitizedFilenameStem}.{ext}Role-based (EDITOR+), not subscriber

Critical: A user with access to one CONTENT-scoped episode must receive a signed URL only for that episode’s MediaAsset, never a listing or prefix grant under {tenant}/private/. The application does not expose ListObjects to clients.

Per-user private prefix

Use {tenant}/private/user/{user_id}/ only for assets that are personally owned, e.g.:

  • Exported GDPR data package (one zip per user)
  • User avatar (if subscribers can upload)
  • Personal feed-builder export cache (Post-MVP)

Do not store shared paid episode audio under user/ — that belongs under private/audio/ with CONTENT scope and product entitlements.

alpha-show-a/
  public/
    audio/7c9e6679-7425-40de-944b-e07fc1f90ae7_episode-42.mp3      # FREE episode
    images/covers/3fa85f64-5717-4562-b3fc-2c963f66afa6_cover-art.jpg
  private/
    audio/2f6b8c1a-9e3d-4a1f-b8c7-1d2e3f4a5b6c_bonus-episode.mp3    # PAID episode (CONTENT)
    documents/8b4c2d1e-0f9a-4b3c-8d7e-6f5a4b3c2d1e_show-notes.pdf   # bonus file (CONTENT)
    user/
      42/9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d_gdpr-export.json      # USER scope — only user 42
  staging/
    {upload_session_id}/recording.wav

MediaAsset fields (storage-relevant)

ColumnPurpose
tenant_idRow-level isolation
s3_keyFull key within bucket
visibilityPUBLIC, PRIVATE
scopeTENANT_PUBLIC, CONTENT, USER, SYSTEM
owner_user_idRequired when scope = USER
episode_id / publication_idRequired when scope = CONTENT
asset_typeAUDIO, IMAGE, VIDEO, DOCUMENT
statusPENDING, READY, ARCHIVED

Unique index: (tenant_id, s3_key). Never reuse s3_key across tenants or assets.


Module gating

Per-tenant feature modules control which asset flows are reachable before any S3 presign runs. See README.md § Feature Modules for the full catalog and dependency graph; this section covers storage implications only.

There is no separate ASSET_STORAGE module. S3 upload, MediaAsset, and the media library are owned by DIGITAL_CONTENT, which is a core module — auto-activated on tenant creation and cannot be deactivated. Optional modules gate vertical flows on top of that foundation.

ConcernModule
S3 media upload, asset metadata, presign flowsDIGITAL_CONTENT (core)
Episode-linked audio (CONTENT scope)DIGITAL_CONTENT + PODCAST
Entitlement-gated private stream/downloadDIGITAL_CONTENT + SUBSCRIPTION
Private RSS enclosuresDIGITAL_CONTENT + PODCAST + PODCAST_RSS (+ SUBSCRIPTION)
Custom subscriber feeds+ FEED_BUILDER
Branding logoUrl (external URL)WHITELABEL (URL only — not S3 upload in MVP)

Gating principles

PrincipleStorage implication
Fail closedDisabled module → 403 FEATURE_NOT_ENABLED before any S3 presign
Check at API boundary@RequiresModule on controllers and explicit checks in feed generators / AssetAccessService
No orphan bytesDo not issue upload presigns for modules the tenant lacks
Frontend syncGET /api/v1/public/site-configenabledModules[] hides media library, upload, stream routes
Deactivation cascadeDeactivating PODCAST disables episode assets; deactivating SUBSCRIPTION disables paid-stream endpoints (existing S3 objects retained until archival — see below)

Module gate runs before AssetAccessService. A tenant without SUBSCRIPTION must not reach entitlement presign logic for paid CONTENT assets — the endpoint returns 403 FEATURE_NOT_ENABLED (module), not ENTITLEMENT_DENIED.

Endpoint → module matrix

Endpoint / flowRequired module(s)When module off
POST /api/v1/media/upload-urlDIGITAL_CONTENT403 — no upload presign
POST /api/v1/media/{id}/confirmDIGITAL_CONTENT403
GET /api/v1/media (library)DIGITAL_CONTENT403
GET /api/v1/media/{id}/preview-urlDIGITAL_CONTENT403
Episode audio attach / streamDIGITAL_CONTENT + PODCAST403 if PODCAST off
Paid episode streamabove + SUBSCRIPTION403 if SUBSCRIPTION off
Private RSS enclosure presignDIGITAL_CONTENT + PODCAST + PODCAST_RSS + SUBSCRIPTION403
Public free episode CDN URLDIGITAL_CONTENT + PODCAST (+ PODCAST_RSS for feed)Public site may hide podcast section
USER-scoped personal assetsDIGITAL_CONTENT403
Branding logoUrl (external URL)WHITELABELFalls back to default branding

Where checks live

java
Controller:  @RequiresModule("DIGITAL_CONTENT")     // HTTP 403 FEATURE_NOT_ENABLED
Service:     moduleGateService.requireModule(...)   // feed generators, AssetAccessService entry
AssetAccess: require SUBSCRIPTION before EntitlementService on paid CONTENT assets

Alpha implementation: ModuleGateService, ModuleManagementService.deactivateModule() (rejects is_core modules with CannotDeactivateCoreModuleException), and Directwerk/docs/multi-tenancy.md.

S3 objects when module deactivated

ScenarioBehaviour
PODCAST deactivatedAPI stops serving episode stream URLs; S3 objects retained (no auto-delete in MVP)
SUBSCRIPTION deactivatedPaid stream endpoints 403; free/public assets unaffected
DIGITAL_CONTENTCannot be deactivateddeactivateModule returns 409 CANNOT_DEACTIVATE_CORE_MODULE; upload/library remain available while active
Tenant offboardedPlatform admin archival job (post-MVP) — prefix delete {tenant_slug}/

Access control model

Authorization is evaluated in layers. Feature modules (above) run first; the tables below describe layers inside AssetAccessService after module and role checks pass.

Layer 0 — Feature modulesLayers 1–3 — per requestInside AssetAccessService403 FEATURE_NOT_ENABLEDPUBLICPRIVATECONTENTUSERSYSTEM"ModuleGateService.requireModule()"TenantContext + JWT tenant_id matchSpring Security role checkAssetAccessServicevisibility PUBLIC vs PRIVATEAssetScope gateEntitlementService for CONTENTAPI requestRejectCDN URLowner_user_id matchEDITOR or TENANT_ADMINS3Presigner presignGet single key

Four independent axes

AxisSourceWhat it controls
Feature moduleTenantModuleActivation + ModuleGateServiceWhich asset flows exist (podcast, paid content, RSS)
TenantTenantContext + MediaAsset.tenant_idRow/key isolation — no cross-tenant reads
VisibilityPUBLIC / PRIVATE on MediaAssetCDN-stable URL vs presigned GET only
AuthorizationAssetScope + role and/or subscriptionWho may obtain a presigned URL

Critical distinction: SUBSCRIBER role ≠ paid access. Role answers “what kind of user are you on this tenant?” Subscription answers “which products/tiers unlock this content?” A user can be SUBSCRIBER with no active subscription (public + locked content only).

Access matrix (role + subscription)

Asset scopeStorage pathAnonymous / GUESTSUBSCRIBER (no sub)SUBSCRIBER (entitled)EDITORTENANT_ADMIN
TENANT_PUBLIC{tenant}/public/...CDNCDNCDNCDNCDN
CONTENT (paid){tenant}/private/audio/{uuid}403403presign if hasAccesspreview draft only*full tenant preview*
CONTENT (free, published)promoted to public/CDNCDNCDNCDNCDN
USER{tenant}/private/user/{userId}/...403owner onlyowner onlyowner only**admin override optional***
SYSTEM{tenant}/private/system/...403403403presignpresign

* Draft preview: explicit previewDraft=true API flag — never bypass entitlements on published paid content.
** Editors do not automatically see another user’s USER-scoped files.
*** Optional TENANT_ADMIN GDPR/support access — document as audited, explicit, post-MVP if needed.

Subscriber tier / product (subscription sub-level)

Subscription granularity lives in PostgreSQL, not in S3 paths:

MechanismModelExample
Tier ladderOfferingType.LEVEL + sort_order“Producer €15” unlocks episodes with required_level_sort_order <= 2
Named bundleOfferingType.PACKAGE + ProductAccessRule“Season 3 Pass” → CATEGORY rule
UnionMultiple active subscriptions rowsTwo packages → access to both scopes

AssetAccessService always calls hasAccess(userId, episodeId) for CONTENT assets — it never inspects S3 keys for category or level. See Group entitlements.

User-scoped private assets — when to use

Use caseScopeWhy not CONTENT?
GDPR export zipUSERNot tied to a product; one owner
Subscriber avatar uploadUSER (or TENANT_PUBLIC if public)Personal, not episodic
Personal feed-builder cacheUSERPost-MVP; per-user artifact
Paid episode MP3CONTENTEntitlement-driven, shared asset
Bonus PDF for a productCONTENT + DIGITAL_ASSET ruleProduct-scoped, not user-scoped

Anti-pattern: storing paid media under {tenant}/private/user/{userId}/ — breaks the entitlement model and enables IDOR if keys are predictable.

Entitlement API contract (storage layer)

The storage layer calls a narrow entitlement interface — it does not re-implement LEVEL or PACKAGE rules:

java
// modules/digital/api/EntitlementApi.java
boolean hasAccess(Long tenantId, Long userId, Long episodeId);
boolean hasDigitalAssetAccess(Long tenantId, Long userId, Long mediaAssetId);

Alpha today: EntitlementService exposes resolveAccess / hasLevelAtLeast only (LEVEL summary). Full hasAccess(contentId) and ProductAccessRule support are Phase 4b — see poc-alpha-setup.md. Until then, AssetAccessService uses a fail-closed stub for paid CONTENT assets.


Preventing “access to one → download all private”

This is the most important security property of the storage layer.

What we do not do

Anti-patternWhy it fails
IAM policy allowing s3:GetObject on {tenant}/private/* for “subscribers”Any leaked URL or token exposes entire prefix
Long-lived signed URL for a directoryS3 does not support this; even “folder” APIs leak keys
Same signed URL for all enclosures in a private RSS feedOne captured URL must not work for other episodes
Client-side “hidden” S3 URLs in API responsesScraping enumerates all private keys
Trusting user_id in key path without DB checkPath guessing / IDOR if keys are predictable

What we do

GET /me/episodes/slug/streamhasAccess user episodepresign single s3_key302 one URL TTL 1hGET signed URLClientPublishAPIPostgreSQLEntitlementServiceS3EU
  1. Single gate: AssetAccessService is the only class that calls S3Presigner.presignGet().
  2. Per-request, per-asset: Load MediaAsset by id; verify tenant_id; run scope-specific check.
  3. Short TTL: 1h for API stream; 24h for RSS enclosures (regenerated each feed fetch).
  4. Opaque keys: UUID filenames — no sequential ids in paths.
  5. No list API: Subscribers never receive ListObjectsV2 credentials or key listings.
  6. Audit optional: Log access decisions (asset id, user id) — never log signed URLs.
java
@Service
@RequiredArgsConstructor
public class AssetAccessService {

    private final EntitlementService entitlementService;
    private final S3Presigner presigner;
    private final S3PublicUrlBuilder publicUrlBuilder;

    public URL resolveDownloadUrl(MediaAsset asset, PublishUserPrincipal principal) {
        assertTenantMatch(asset);

        if (asset.getVisibility() == Visibility.PUBLIC) {
            return publicUrlBuilder.cdnUrl(asset.getS3Key());
        }

        authorizePrivateAsset(asset, principal);

        return presigner.presignGet(
            PresignRequest.builder()
                .signatureDuration(ttlFor(asset))
                .getObjectRequest(b -> b.bucket(bucket).key(asset.getS3Key()))
                .build()
        ).url();
    }

    private void authorizePrivateAsset(MediaAsset asset, PublishUserPrincipal principal) {
        switch (asset.getScope()) {
            case CONTENT -> {
                boolean entitled = asset.getEpisodeId() != null
                        ? entitlementService.hasAccess(
                                asset.getTenantId(), principal.getUserId(), asset.getEpisodeId())
                        : entitlementService.hasDigitalAssetAccess(
                                asset.getTenantId(), principal.getUserId(), asset.getId());
                if (!entitled) {
                    throw new EntitlementDeniedException(asset.getId());
                }
            }
            case USER -> {
                if (!principal.getUserId().equals(asset.getOwnerUserId())) {
                    throw new EntitlementDeniedException(asset.getId());
                }
            }
            case SYSTEM -> {
                if (!principal.hasAnyRole("EDITOR", "TENANT_ADMIN")) {
                    throw new AccessDeniedException();
                }
            }
            default -> throw new IllegalStateException("Unexpected scope: " + asset.getScope());
        }
    }
}

RSS private feeds: For each episode in the feed XML, call resolveDownloadUrl individually. Episodes the subscriber is not entitled to are omitted — not included with a locked placeholder URL.

Publisher preview: EDITOR role bypasses entitlement for draft assets in tenant only via explicit previewDraft flag — never for published CONTENT assets owned by another product line.


Group entitlements (LEVEL vs PACKAGE)

Product-based access (LEVEL ladder, PACKAGE rules, union evaluation) is documented in content-subscriptions-and-entitlements.md.

Storage layer reminder: entitlements live in PostgreSQL — not S3 prefixes. Each stream or download still runs hasAccess + presignGet for one MediaAsset after entitlement passes.


Upload flow

Never stream file bytes through the Spring API.

EditorAPIDBS3POST /api/v1/media/upload-urlInsert MediaAsset PENDING staging keypreSignedPutUrl assetId expiresInPUT staging objectPOST /api/v1/media/{id}/confirmHEAD checksum size mimeCopyObject staging to private or public prefixDeleteObject staging file + session folder markerREADY update s3_key visibility200 asset metadata no signed GET

POST /api/v1/media/upload-url

Request:

json
{
  "filename": "episode-42.mp3",
  "mimeType": "audio/mpeg",
  "sizeBytes": 52428800,
  "assetType": "AUDIO",
  "intendedVisibility": "PRIVATE",
  "scope": "CONTENT",
  "episodeId": null
}

Validations:

CheckRule
TenantTenantContext.getTenantId()
RoleEDITOR or TENANT_ADMIN
Mime allow-listPer assetType (e.g. audio: audio/mpeg, audio/mp4)
Max sizee.g. 500 MB audio, 10 MB images
Key{tenant}/staging/{uploadSessionUuid}/{sanitizedFilename} — session id server-generated

Response:

json
{
  "data": {
    "assetId": 1001,
    "uploadUrl": "https://...",
    "expiresAt": "2026-07-16T08:00:00Z",
    "headers": { "Content-Type": "audio/mpeg" }
  }
}

Pre-signed PUT conditions (SigV4 policy):

  • Content-Type must match declared mime
  • Content-Length max = declared sizeBytes
  • Key must equal the issued staging key exactly

Confirm and promote

On POST /api/v1/media/{id}/confirm:

  1. HEAD staging object — verify exists, size, checksum SHA-256
  2. CopyObject → final key under public/ or private/ based on intendedVisibility and publish rules
  3. DeleteObject staging file and its session folder marker ({tenant}/staging/{session}/). Bunny creates explicit folder objects for key prefixes, so deleting the file alone leaves an empty directory behind. StagingCleanupService.deleteStagingKeyAndFolder removes the file plus the folder marker with and without a trailing slash. If S3 is unavailable, a MEDIA_STAGING_CLEANUP queue job retries the delete later instead of rolling back the confirm.
  4. Update MediaAsset.s3_key, status = READY

On episode publish (access_policy = FREE), AssetPromotionService moves audio from private/audio/public/audio/ and updates visibility.


Retrieval flow

Public assets

ConsumerResponse
GET /api/v1/public/episodesaudioUrl: "https://cdn.example.com/alpha-show-a/public/audio/{uuid}_{sanitizedFilenameStem}.mp3"
Public RSSPermanent CDN URL in <enclosure url="...">
WebsiteDirect CDN URL from site-config / episode payload

CDN origin points at bucket path {tenant}/public/ or a dedicated public bucket. Cache-Control: public, max-age=31536000, immutable for UUID-keyed media.

Private assets

ConsumerEndpointGate
Subscriber appGET /api/v1/me/episodes/{slug}/streamJWT + per-episode entitlement
Subscriber downloadsGET /api/v1/me/downloadsJWT; list only entitled publications
Private RSSGET /feeds/{tenant}/u/{feedToken}.xmlFeed token + per-episode entitlement
PublisherGET /api/v1/media/{id}/preview-urlJWT + EDITOR + tenant match

All private paths return 302 Redirect to a fresh pre-signed URL (or JSON with short-lived URL if client prefers XHR).

User-scoped assets

GET /api/v1/me/assets/{id} — only when asset.scope == USER and asset.owner_user_id == principal.userId.


Spring configuration

Dependencies (build.gradle.kts)

kotlin
implementation("software.amazon.awssdk:s3")
implementation("software.amazon.awssdk:s3-transfer-manager") // optional multipart

Properties (S3StorageProperties)

yaml
directwerk:
  storage:
    provider: hetzner          # hetzner | bunny
    region: eu-central
    bucket: directwerk-prod
    public-bucket:             # optional dual-bucket mode
    endpoint: https://nbg1.your-objectstorage.com
    force-path-style: false    # true for Bunny
    access-key: ${S3_ACCESS_KEY}
    secret-key: ${S3_SECRET_KEY}
    public-cdn-base-url: https://cdn.directwerk.example.com
    presign-upload-ttl: 15m
    presign-download-ttl-api: 1h
    presign-download-ttl-rss: 24h
    staging-lifecycle-hours: 24
    staging-cleanup-interval-ms: 3600000

Hetzner client bean

java
@Bean
S3Client s3Client(S3StorageProperties props) {
    return S3Client.builder()
        .endpointOverride(URI.create(props.getEndpoint()))
        .region(Region.of("eu-central-1"))   // SDK requires a region; endpoint drives routing
        .credentialsProvider(StaticCredentialsProvider.create(
            AwsBasicCredentials.create(props.getAccessKey(), props.getSecretKey())))
        .forcePathStyle(props.isForcePathStyle())
        .build();
}

Bunny client bean

java
// endpoint: https://de-s3.storage.bunnycdn.com
// forcePathStyle: true
// accessKey: storage zone name
// secretKey: zone password

Dev profile (application-dev.yml)

Use the same provider as production, pointing at a dev bucket. Credentials via env vars (see .env.local.example — never commit secrets).

Hetzner (recommended):

yaml
directwerk:
  storage:
    provider: hetzner
    endpoint: https://nbg1.your-objectstorage.com
    force-path-style: false
    bucket: directwerk-dev
    access-key: ${S3_ACCESS_KEY}
    secret-key: ${S3_SECRET_KEY}
    public-cdn-base-url: ${S3_PUBLIC_CDN_BASE_URL:https://directwerk-dev.nbg1.your-objectstorage.com}

Bunny.net:

yaml
directwerk:
  storage:
    provider: bunny
    endpoint: https://de-s3.storage.bunnycdn.com
    force-path-style: true
    bucket: directwerk-dev          # storage zone name
    access-key: ${S3_ACCESS_KEY} # zone name
    secret-key: ${S3_SECRET_KEY} # zone password
    public-cdn-base-url: ${S3_PUBLIC_CDN_BASE_URL}
VariablePurpose
S3_ENDPOINTProvider endpoint (region-specific)
S3_BUCKETBucket / storage zone name
S3_ACCESS_KEYHetzner access key or Bunny zone name
S3_SECRET_KEYHetzner secret key or Bunny zone password
S3_PUBLIC_CDN_BASE_URLCDN origin for {tenant}/public/ URLs

CDN setup

Hetzner + CDN / Cloudflare

  1. Origin: bucket public endpoint or directwerk-{env}-public bucket
  2. Pull zone / Cloudflare CNAME: cdn.{platform-domain}.de
  3. Cache only /{tenant}/public/**
  4. Never cache private/, staging/, or user/ paths — block at CDN edge via path rules

Bunny

  1. Storage zone (S3-enabled) in de
  2. Pull zone linked to zone; custom host cdn.tenant.example
  3. Same path rules — public prefixes only

Tenant branding may use per-tenant CDN hostnames (Post-MVP) — still backed by same key layout.


IAM and credentials

Application credentials (Coolify env):

PermissionScope
s3:PutObject{bucket}/{tenant}/staging/* only via presigned PUT (app signs)
s3:GetObjectFull bucket (app signs per key after entitlement)
s3:DeleteObjectStaging + admin delete
s3:ListBucketApplication internal only — never exposed to tenants/users

Do not create per-tenant IAM users. One service account per environment.

Bucket policy (private bucket): deny s3:GetObject for Principal: *.


Lifecycle rules

Bunny.net Storage does not support S3 bucket lifecycle policies, and Hetzner does not expire keys by prefix either. Staging cleanup is therefore application-side, not a bucket rule:

PrefixRule
{tenant}/staging/App purges objects (files and folder markers) older than directwerk.storage.staging-lifecycle-hours (default 24h)
{tenant}/private/No auto-expire; delete via app on asset archive
Incomplete multipart uploadsAbort after 7 days (provider/ops-level, post-MVP)

Staging cleanup job

A recurring Quartz job (MediaStagingCleanupJob) calls StagingCleanupService.cleanupExpiredStaging() on an interval derived from directwerk.storage.staging-cleanup-interval-ms (minimum 60s). It lists {tenant}/staging/ per tenant (ListObjectsV2), deletes every expired object including Bunny folder markers, and tombstones any still-PENDING MediaAsset whose staging object was purged as ARCHIVED.

staging-lifecycle-hours therefore has two uses:

  1. Inline cleanup: a successful confirm deletes the staging file and its session folder immediately.
  2. Background sweep: abandoned staging objects (failed uploads, never-confirmed assets) are removed by the Quartz job once they are older than the configured hours.

Both are idempotent — missing objects (NoSuchKey / HTTP 404) are ignored.


Error codes

CodeHTTPWhen
FEATURE_NOT_ENABLED403Required feature module not active for tenant (before S3 presign)
ASSET_NOT_FOUND404Unknown asset id or wrong tenant
ENTITLEMENT_DENIED403Private CONTENT asset without subscription
ASSET_ACCESS_DENIED403USER scope — wrong user
UPLOAD_VALIDATION_FAILED400Mime, size, checksum mismatch
STAGING_EXPIRED410Confirm after staging TTL

Testing checklist

#Scenario
1Upload confirm moves object from staging/ to private/audio/
2FREE episode publish promotes asset to public/audio/
3Subscriber entitled to episode A gets 302 for A only
4Same subscriber denied episode B stream → 403 ENTITLEMENT_DENIED
5Captured signed URL for A does not work for B’s key
6Tenant A credentials cannot GetObject on tenant B key (integration test)
7USER scope asset readable only by owner
8Public RSS never contains private/ URL
9Private RSS regenerates new signed URLs on each fetch
10Staging object deleted after 24h lifecycle
11Pre-signed PUT rejects wrong Content-Type
12No pre-signed URL appears in application logs
13PACKAGE + CATEGORY rule — subscriber streams episode in category
14Same subscriber denied episode outside category
15LEVEL sort_order 2 grants episodes at levels 1–2, not 3
16Two active PACKAGE products — union grants episodes from both scopes
17Private RSS — only entitled category episodes, unique signed URLs per enclosure
18Revoked subscription — next stream returns ENTITLEMENT_DENIED
19Tenant without PODCAST → episode stream returns FEATURE_NOT_ENABLED
20Tenant without SUBSCRIPTION → paid stream returns FEATURE_NOT_ENABLED (not ENTITLEMENT_DENIED)

Unit tests mock the S3 client. Optional nightly integration job uses the Hetzner/Bunny dev bucket with CI-injected credentials.

Group entitlement scenarios (13–18): see Group entitlements.


Implementation phases

PhaseDeliverable
2aS3StorageProperties, S3Client/S3Presigner beans (Hetzner + Bunny profiles)
2bMediaAsset entity + Flyway V4__create_digital_content.sql
2cUploadService — presigned PUT + confirm + promote
2dAssetAccessService + EntitlementService interface (stub until Phase 4b)
2ePublic CDN URL builder; episode stream 302 endpoint
2fBunny provider profile (forcePathStyle) + deployment doc
4RSS enclosure signing via AssetAccessService per episode

Directwerk — whitelabel publication infrastructure