On this page

File storage

A disk is a named storage connection. App opens selected disks and shares native Go CDK *blob.Bucket values. Use those buckets for exports, documents or other raw objects. For owned uploads, public/private asset URLs and deletion recovery, use Media above these primitives.

Select a disk#

Start locally without a cloud provider:

FILESYSTEM_DISK=local
FILESYSTEM_LOCAL_ROOT=storage/app

FILESYSTEM_DISK selects a name, not a driver. Named definitions live in internal/app/config/filesystems.go:

Disk name Driver Environment prefix
local Local filesystem FILESYSTEM_LOCAL_
s3 Amazon S3 FILESYSTEM_S3_
minio S3-compatible MinIO FILESYSTEM_MINIO_
spaces S3-compatible Spaces FILESYSTEM_SPACES_
gcs Google Cloud Storage FILESYSTEM_GCS_
azure Azure Blob Storage FILESYSTEM_AZURE_

App opens the default plus FILESYSTEM_ADDITIONAL_DISKS once. disabled omits the default, but explicitly selected additional disks still open. Unselected providers need no credentials/bucket. Media URL settings are validated for all definitions; provider requirements are validated when a disk is selected or explicitly opened.

The Go default selects MinIO; the tutorial profile disables storage. Tilt enables MinIO by default, while Compose needs its storage profile. See local development.

Try a complete storage operation#

APP_ENV_FILE='' FILESYSTEM_DISK=local go run ./examples/storage

Expect Hello from the configured filesystem. The example writes a uniquely named small object, reads it, deletes it, and closes its connection. No full app or migration is needed. It uses the existing configuration loader but constructs only storage; inherited process settings still affect configuration validation.

Inject and use the bucket#

During module construction, choose from resources.Disks and pass that bucket to the service that needs it. Fail construction if an enabled operation requires an unavailable disk; do not look it up from the resource bundle on every call.

For a small bounded object inside the service:

if err := bucket.WriteAll(ctx, "exports/example.txt", []byte("hello"), nil); err != nil {
    return err
}
body, err := bucket.ReadAll(ctx, "exports/example.txt")
if err != nil {
    return err
}

This operation fragment assumes a borrowed *blob.Bucket and context. Consume body as the result. App owns the connection; the service must not close it. Generate or validate object keys in the business operation. A request filename is not an authorization rule.

Stream larger objects#

Use readers/writers when the object should not be buffered in memory. Complete helper for a borrowed bucket, authorized key and caller-owned reader:

package files

import (
	"context"
	"errors"
	"io"

	"gocloud.dev/blob"
)

func Upload(ctx context.Context, bucket *blob.Bucket, key string, src io.Reader) error {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()
	writer, err := bucket.NewWriter(ctx, key, nil)
	if err != nil {
		return err
	}
	_, copyErr := io.Copy(writer, src)
	if copyErr != nil {
		cancel()
	}
	return errors.Join(copyErr, writer.Close())
}

The caller bounds input size and duration. Writer Close finalizes an upload and can fail; preserve its error. Cancel before closing if input copying fails. Cancellation still cannot prove that a remote write rolled back after acceptance. Use stable keys and reconciliation when an outcome is uncertain.

Read and close an object#

reader, err := bucket.NewReader(ctx, key, nil)
if err != nil {
    return err
}
_, copyErr := io.Copy(destination, reader)
return errors.Join(copyErr, reader.Close())

This function-body fragment imports io and errors; destination is an io.Writer. Finish all readers/writers before the owner closes the connection. Native range reads are available when your operation needs only part of an object.

Open another named disk#

Add a name to the definitions in FilesystemConfig.load:

"invoices": {Driver: "s3"},

Then supply its own settings and select it alongside the default:

FILESYSTEM_ADDITIONAL_DISKS=invoices
FILESYSTEM_INVOICES_BUCKET=example-invoices
FILESYSTEM_INVOICES_REGION=eu-west-1

Use provider-managed credentials or the disk's explicit credential fields. Names and drivers are defined in Go; setting an environment prefix does not create a new disk. Settings are immutable after assembly.

For a connection you own independently:

options, err := cfg.Filesystem.StorageConfig("invoices")
if err != nil {
    return err
}
connection, err := storage.Open(ctx, options)
if err != nil {
    return err
}

This acquisition fragment imports github.com/wisalgroup/labena/pkg/storage. Pass connection.Bucket to consumers, then preserve connection.Close() errors after all users finish. Calls are not cached. See examples/storage/main.go for a full function with cleanup, or the pkg/storage/README.md constructor reference.

Listing, deletion and signed URLs#

Native operation Use
Attributes Read size and object metadata
List Iterate a prefix with a caller-defined result limit
NewRangeReader Read a byte range
Copy Copy an object within the bucket
Delete Remove an object
SignedURL Grant temporary provider access

Handle iterator errors as well as the end of iteration. Use gcerrors.Code(err) for portable error classification. Copy then delete is not a portable atomic rename; blob operations do not become atomic with SQL.

After authorizing an object, issue a native download link:

url, err := bucket.SignedURL(ctx, key, &blob.SignedURLOptions{
    Expiry: 5 * time.Minute,
})
if err != nil {
    return err
}

This fragment imports time and gocloud.dev/blob. Its url is a bearer credential. Store the disk/key, not the expiring link. A signed PUT URL is possible through native options, but does not verify that an uploaded file is safe or create a managed Media asset.

Provider Signing requirement in this integration
S3-compatible SDK signing credentials and a client-reachable endpoint
GCS Service-account credentials containing email/private key; IAM remote signing is not configured
Azure Shared-key client; signed PUT also needs x-ms-blob-type: BlockBlob
Local Native signing is not configured; use Media's internal links

Media visibility and URLs#

These disk settings apply to Media assets, not raw bucket writes or operating system permissions:

FILESYSTEM_MINIO_VISIBILITY=public
FILESYSTEM_MINIO_URL_MODE=internal
FILESYSTEM_MINIO_PUBLIC_URL=http://localhost:9000/uploads
MEDIA_BASE_URL=http://localhost:8080/api/v1/media
Setting Controls
VISIBILITY=public|private Default for new uploads; an explicit upload value overrides it
URL_MODE=internal Return application content/download routes
URL_MODE=direct Public permanent provider URLs and private signed provider URLs
PUBLIC_URL Browser-reachable public bucket/CDN root, including any bucket path
MEDIA_BASE_URL Application media route origin/prefix; not the storage endpoint

Direct mode requires PUBLIC_URL, even when the default visibility is private. Local disks require internal mode. The Media URL matrix shows precisely which links require a bearer header and which expire.

Configure a direct public root#

Examples:

S3 virtual-host: https://BUCKET.s3.REGION.amazonaws.com
MinIO path-style: https://storage.example.com/BUCKET
CDN:             https://media.example.com

This setting builds public URLs. Private signatures use the native client's Endpoint, not PublicURL. Compose/Tilt's internal http://minio:9000 is useful for app-to-storage traffic but inaccessible to an ordinary browser outside that network. Choose an endpoint reachable by both before enabling private direct links.

Do not rewrite the hostname after signing: the signature includes it. Temporary provider credentials may expire sooner than the requested link lifetime.

Grant public access to public objects only#

Media writes public assets under public/assets/<id> and private assets under assets/<id>. For permanent public links, provision anonymous read only on public/*, leaving uploads/deletes authenticated. Example S3/MinIO statement for an uploads bucket:

{
  "Effect": "Allow",
  "Principal": "*",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::uploads/public/*"
}

Place this in a bucket policy's Statement array with version 2012-10-17 and check account-level public-access restrictions. The framework does not change ACLs or policies. For providers lacking suitable prefix restrictions, use separate public and private disks instead of exposing a mixed container.

Direct reads bypass app headers, visibility/deletion status checks and rate limits. Objects may be readable before metadata is ready, and CDN caches may retain deleted bytes. Use provider/CDN controls and a separate media origin for untrusted content. Changing to internal URL mode does not revoke existing public storage permissions.

Legacy public objects outside public/ retain internal URLs until explicitly copied and their keys updated. Configuration changes do not move objects.

Storage limits and verification#

Remote buckets/containers must already exist. Constructing a client does not probe bucket existence or permissions; the first operation can still fail. Use HTTPS for remote endpoints. Workload identity or operator-managed credentials belong in configuration, never upload inputs.

Local roots are created with private permissions; existing permissions are not rewritten. Keep Go CDK attribute sidecars when backing up. Protect the root against untrusted writes/symlinks and provision shared storage deliberately for replicas.

Symptom Check
App starts but upload is denied Bucket existence and operation-specific credentials
An additional disk is missing Add its Go definition and select it in Additional disks
File exists on one replica only Local storage is host-specific without a shared volume
Read succeeds but signing fails Provider-specific signing credentials
Direct URL opens only inside a container Endpoint/PublicURL reachability

Local and MinIO tests do not prove real-cloud permissions or recovery semantics. Use disposable provider acceptance resources for the operations you rely on.

Media asset URLs#

For database-backed uploads, use Media and its HTTP url field or Library.URL(ctx, asset) after authorization. The library returns a Link; HTTP asset responses map its expiry to url_expires_at. Explicit temporary links return expires_at. See private-link examples for client code and signing-failure behavior.

Documentation

Search guides, sections, and API names.