{"title":"Storage Backends","description":"Configure FileSystem, S3, and Memory storage for file uploads","section":"guides/uploads","version":"v2","path":"guides/uploads/storage","canonical_url":"https://amberframework.org/docs/v2/guides/uploads/storage","markdown_url":"https://amberframework.org/docs/v2/guides/uploads/storage.md","inherited":false,"content_markdown":"# Storage Backends\n\n> **Preview ecosystem guide:** Gemma is not part of the Amber 2.0.0-beta.5\n> core web-app release gate. Its package version, API, and platform support may\n> change independently. Confirm a compatible official release before adding it\n> to an application.\n\nGemma supports multiple storage backends for flexibility across different environments. All storages implement the same interface, allowing you to switch backends without changing application code.\n\nThese are runtime uploads, not application assets. Logos, stylesheets,\nJavaScript, fonts, and other release-owned files belong in [Asset\nPipeline](../assets/) and its build manifest. Never run user-controlled uploads\nthrough an asset build or cache them under an immutable authored-asset URL.\n\n## Where the examples go\n\nStorage construction and Gemma-wide configuration belong in\n`config/uploads.cr`. The generated application entry point loads top-level\n`config/*` files before application source. Direct upload, URL, and metadata\noperations belong in the controller, job, service, or spec that owns the file\noperation. Test-only memory storage belongs in `spec/spec_helper.cr`. Directory\ntrees on this page describe runtime output, not source files to create by hand.\n\n## Configuration\n\n**File: `config/uploads.cr` — create this setup. Keep one `Gemma.configure`\nblock and extend it as storage needs grow.**\n\n```crystal\nrequire \"gemma\"\n\nGemma.configure do |config|\n  # Temporary storage (for uploads in progress)\n  config.storages[\"cache\"] = Gemma::Storage::FileSystem.new(\n    \"uploads\",\n    prefix: \"cache\"\n  )\n\n  # Permanent storage\n  config.storages[\"store\"] = Gemma::Storage::FileSystem.new(\"uploads\")\nend\n```\n\n**File: the application entry point, for example `src/my_app.cr` — retain\n`require \"../config/*\"` before controllers and models.** If a migrated app does\nnot use that generated wildcard, explicitly require `../config/uploads`.\n\n## FileSystem Storage\n\nStore files on the local filesystem for development or a deliberately\nsingle-host deployment with a persistent mounted disk, backups, and an explicit\ndelivery route. A container's writable layer and a release directory replaced\nduring deployment are not durable upload storage.\n\n### Basic Configuration\n\n```crystal\nGemma::Storage::FileSystem.new(\n  \"uploads\"  # Base directory\n)\n```\n\n### Full Configuration\n\n```crystal\nGemma::Storage::FileSystem.new(\n  \"uploads\",                    # Base directory\n  prefix: \"attachments\",        # Subdirectory prefix\n  permissions: 0o644,           # File permissions (default)\n  directory_permissions: 0o755, # Directory permissions (default)\n  clean: true                   # Auto-clean empty directories (default)\n)\n```\n\n### Options\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `directory` | String | Required | Base directory for file storage |\n| `prefix` | String? | `nil` | Subdirectory within base directory |\n| `permissions` | Int | `0o644` | UNIX permissions for files |\n| `directory_permissions` | Int | `0o755` | UNIX permissions for directories |\n| `clean` | Bool | `true` | Remove empty parent directories on delete |\n\n### URL Generation\n\nThe filesystem directory and the browser URL are separate configuration\ndecisions. Project-root `uploads/` is private by default because Amber's\ngenerated static route serves only `public/`. The following public-directory\nexample is appropriate only for uploads that are intentionally public and have\nalready passed validation:\n\n```crystal\nstorage = Gemma::Storage::FileSystem.new(\"public/uploads\", prefix: \"files\")\n\n# URLs are relative paths\nstorage.url(\"abc123.jpg\")\n# => \"/files/abc123.jpg\"\n\n# With host\nstorage.url(\"abc123.jpg\", host: \"https://cdn.example.com\")\n# => \"https://cdn.example.com/files/abc123.jpg\"\n```\n\nRequest the returned URL in a deployment smoke test. If it does not correspond\nto the configured Amber route, use an authenticated download action or the\nstorage backend's own URL instead of guessing a prefix.\n\n### Directory Structure\n\n```\nuploads/                  # private, persistent runtime storage\n├── cache/                # temporary files (prefix: \"cache\")\n│   └── abc123.jpg\n└── store/                # permanent files (prefix: \"store\")\n    └── def456.pdf\n```\n\nDo not place the temporary cache under `public/`. If permanent uploads are\npublic, use an unpredictable immutable key or an authorization layer; never\ntrust the original filename as a safe path.\n\n## S3 Storage\n\nStore files in Amazon S3 or S3-compatible services (DigitalOcean Spaces, MinIO, etc.).\n\n### Basic Configuration\n\n```crystal\nrequire \"gemma\"\n\nclient = Awscr::S3::Client.new(\n  region: \"us-east-1\",\n  aws_access_key: ENV[\"AWS_ACCESS_KEY_ID\"],\n  aws_secret_key: ENV[\"AWS_SECRET_ACCESS_KEY\"]\n)\n\nGemma::Storage::S3.new(\n  bucket: \"my-app-uploads\",\n  client: client\n)\n```\n\n### Full Configuration\n\n```crystal\nstorage = Gemma::Storage::S3.new(\n  bucket: \"my-app-uploads\",\n  client: client,\n  prefix: \"attachments\",        # Key prefix in bucket\n  public: false,                # Set public ACL on upload\n  upload_options: {             # Default upload options\n    \"x-amz-acl\" => \"private\",\n    \"Cache-Control\" => \"private, no-store\"\n  }\n)\n```\n\nFor a genuinely public object whose key changes with its contents, a long\n`public, max-age=31536000, immutable` policy can be appropriate. Mutable object\nkeys need short revalidation. Private and presigned objects need a policy\nappropriate to their access controls; do not copy the authored-asset cache\npolicy blindly.\n\n### Options\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `bucket` | String | Required | S3 bucket name |\n| `client` | Awscr::S3::Client | Required | S3 client instance |\n| `prefix` | String? | `nil` | Key prefix for all objects |\n| `public` | Bool | `false` | Make uploads publicly readable |\n| `upload_options` | Hash | `{}` | Default headers for uploads |\n\n### S3-Compatible Services\n\n#### DigitalOcean Spaces\n\n```crystal\nclient = Awscr::S3::Client.new(\n  region: \"nyc3\",\n  aws_access_key: ENV[\"SPACES_ACCESS_KEY\"],\n  aws_secret_key: ENV[\"SPACES_SECRET_KEY\"],\n  endpoint: \"https://nyc3.digitaloceanspaces.com\"\n)\n\nstorage = Gemma::Storage::S3.new(\n  bucket: \"my-space\",\n  client: client,\n  public: true  # Spaces URLs are typically public\n)\n```\n\n#### MinIO\n\n```crystal\nclient = Awscr::S3::Client.new(\n  region: \"us-east-1\",\n  aws_access_key: ENV[\"MINIO_ACCESS_KEY\"],\n  aws_secret_key: ENV[\"MINIO_SECRET_KEY\"],\n  endpoint: \"http://localhost:9000\"\n)\n\nstorage = Gemma::Storage::S3.new(\n  bucket: \"uploads\",\n  client: client\n)\n```\n\n### URL Generation\n\nS3 storage generates presigned URLs:\n\n```crystal\n# Presigned URL (default, time-limited)\nstorage.url(\"abc123.jpg\")\n# => \"https://bucket.s3.amazonaws.com/abc123.jpg?X-Amz-...\"\n\n# For public buckets, you may want direct URLs\n# Configure your application to generate these\n```\n\n### Public Access\n\n```crystal\n# Make all uploads public\nstorage = Gemma::Storage::S3.new(\n  bucket: \"public-assets\",\n  client: client,\n  public: true  # Sets x-amz-acl: public-read\n)\n\n# Or per-upload via upload_options\nstorage.upload(file, \"key\", upload_options: {\"x-amz-acl\" => \"public-read\"})\n```\n\n## Memory Storage\n\nIn-memory storage for testing. Files are not persisted.\n\n```crystal\nGemma::Storage::Memory.new\n```\n\n### Testing Configuration\n\n```crystal\n# spec/spec_helper.cr\nGemma.configure do |config|\n  config.storages[\"cache\"] = Gemma::Storage::Memory.new\n  config.storages[\"store\"] = Gemma::Storage::Memory.new\nend\n```\n\n## Environment-Based Configuration\n\nConfigure different storages per environment:\n\n**File: `config/uploads.cr` — replace the earlier `Gemma.configure` block\nwith this environment-aware version; do not define both.**\n\n```crystal\nrequire \"gemma\"\n\nGemma.configure do |config|\n  # Cache storage (same for all environments)\n  config.storages[\"cache\"] = Gemma::Storage::FileSystem.new(\n    \"uploads\",\n    prefix: \"cache\"\n  )\n\n  # Store storage (varies by environment)\n  case ENV[\"AMBER_ENV\"]?\n  when \"production\"\n    client = Awscr::S3::Client.new(\n      region: ENV[\"AWS_REGION\"],\n      aws_access_key: ENV[\"AWS_ACCESS_KEY_ID\"],\n      aws_secret_key: ENV[\"AWS_SECRET_ACCESS_KEY\"]\n    )\n\n    config.storages[\"store\"] = Gemma::Storage::S3.new(\n      bucket: ENV[\"S3_BUCKET\"],\n      client: client,\n      prefix: \"uploads\"\n    )\n\n  when \"test\"\n    config.storages[\"store\"] = Gemma::Storage::Memory.new\n\n  else # development\n    config.storages[\"store\"] = Gemma::Storage::FileSystem.new(\n      \"uploads\",\n      prefix: \"store\"\n    )\n  end\nend\n```\n\n## Storage Interface\n\nAll storages implement these methods:\n\n```crystal\n# Upload a file\nstorage.upload(io, \"path/to/file.jpg\")\n\n# Check if file exists\nstorage.exists?(\"path/to/file.jpg\")  # => true/false\n\n# Get file URL\nstorage.url(\"path/to/file.jpg\")  # => \"https://...\"\n\n# Open file for reading\nstorage.open(\"path/to/file.jpg\")  # => IO\n\n# Delete file\nstorage.delete(\"path/to/file.jpg\")\n\n# Get full path/key\nstorage.path(\"path/to/file.jpg\")  # => \"uploads/path/to/file.jpg\"\n```\n\n## Direct Usage\n\nYou can use storages directly without models:\n\n```crystal\n# Upload file\nstorage = Gemma.find_storage(\"store\")\nstorage.upload(File.open(\"document.pdf\"), \"documents/report.pdf\")\n\n# Or via Gemma class\nuploaded_file = Gemma.upload(File.open(\"photo.jpg\"), \"store\")\n\n# Access the file\nuploaded_file.url       # URL to file\nuploaded_file.exists?   # Check existence\nuploaded_file.delete    # Remove file\n```\n\n## Custom Metadata\n\nPass metadata during upload:\n\n```crystal\nGemma.upload(\n  file,\n  \"store\",\n  metadata: {\n    \"filename\" => \"report.pdf\",\n    \"mime_type\" => \"application/pdf\",\n    \"size\" => file.size.to_s\n  }\n)\n```\n\nFor S3, metadata is used for Content-Disposition:\n\n```crystal\n# Sets Content-Disposition: inline; filename=\"report.pdf\"\nstorage.upload(\n  file,\n  \"key\",\n  metadata: {\"filename\" => \"report.pdf\"}\n)\n```\n\n## Best Practices\n\n### 1. Separate Cache and Store\n\nAlways configure both storages:\n\n```crystal\nconfig.storages[\"cache\"] = ...  # Temporary uploads\nconfig.storages[\"store\"] = ...  # Permanent storage\n```\n\n### 2. Use Environment Variables\n\nNever hardcode credentials:\n\n```crystal\nclient = Awscr::S3::Client.new(\n  region: ENV[\"AWS_REGION\"],\n  aws_access_key: ENV[\"AWS_ACCESS_KEY_ID\"],\n  aws_secret_key: ENV[\"AWS_SECRET_ACCESS_KEY\"]\n)\n```\n\n### 3. Set Appropriate Permissions\n\nFor FileSystem, restrict access:\n\n```crystal\nGemma::Storage::FileSystem.new(\n  \"uploads\",\n  permissions: 0o600,           # Owner read/write only\n  directory_permissions: 0o700  # Owner full access only\n)\n```\n\n### 4. Configure delivery for production\n\nPrefer a URL produced by the configured storage backend. It can preserve\nsignatures, expiry, host, and key encoding. Do not form a CDN URL by concatenating\nan arbitrary hostname with a path returned for a different origin.\n\n```crystal\n# The configured backend owns URL generation.\navatar_url = user.avatar.try(&.url)\n```\n\nFor public objects behind a CDN, configure the storage/CDN origin and public\nhost together, then test one upload, one fetch, one replacement, and one delete.\nFor private objects, use authenticated application delivery or time-limited\npresigned URLs.\n\n### 5. Clean Up Cache Periodically\n\nCached files should be temporary. Clean them periodically:\n\n```crystal\n# Cron job or scheduled task\nDir.glob(\"uploads/cache/**/*\").each do |path|\n  if File.file?(path) && File.info(path).modification_time < 1.day.ago\n    File.delete(path)\n  end\nend\n```"}