> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sevalla.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Store Rails uploads in object storage

> Store Active Storage uploads in a Sevalla object storage bucket with the S3 service: direct uploads from the browser with a CORS rule, and image variants with libvips.

Active Storage saves uploaded files on the local disk by default, and Sevalla resets an application's filesystem on every deployment. With Active Storage's S3 service, Rails saves them in a Sevalla [object storage](https://docs.sevalla.com/object-storage/overview.md) bucket instead, through its S3-compatible API, so every instance and every deployment sees the same files. Browsers can upload straight to the bucket, and Rails resizes images with libvips, which both build strategies already include.

## At a glance

What this guide adds to the application from [Deploy a Rails application](https://docs.sevalla.com/guides/ruby/rails/deploy.md#at-a-glance):

| | |
| - | - |
| Services | A private Sevalla object storage bucket, reached over its S3-compatible API, with a CORS rule for uploads from the browser |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `S3_BUCKET` | Yes | Runtime | No | The bucket's **Bucket name** (`bucket_name` in the API), for example `storefront-media-q8w2e` |
| `S3_ENDPOINT` | Yes | Runtime | No | The bucket's **Endpoint** (`endpoint`), `https://<account-id>.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | Yes | Runtime | Yes | The bucket's **Access key** (`access_key`) |
| `S3_SECRET_ACCESS_KEY` | Yes | Runtime | Yes | The bucket's **Secret key** (`secret_key`) |

The application's other variables are in [Deploy a Rails application](https://docs.sevalla.com/guides/ruby/rails/deploy.md#environment-variables).

## Before you begin

* A Rails application deployed on Sevalla, as in [Deploy a Rails application](https://docs.sevalla.com/guides/ruby/rails/deploy.md).

## Create a bucket

In the sidebar, click **Object storage**, then click **Create object storage**. Enter `storefront-media` as the **Name**, choose the **Location** closest to the application, and click **Create object storage**. Keep the bucket private: Rails links to each file with a URL that expires.

## Add the bucket details to the application

The values come from the bucket's **Settings** tab. See [Connect with the S3 API](https://docs.sevalla.com/object-storage/connect.md) for each one.

| Variable | Where to find it |
| - | - |
| `S3_BUCKET` | **Bucket name** under **Details**, for example `storefront-media-q8w2e`. It's not the display name. |
| `S3_ENDPOINT` | **Endpoint** under **Details**, in the form `https://<account-id>.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | **Access key** under **Access keys** |
| `S3_SECRET_ACCESS_KEY` | **Secret key** under **Access keys** |

**1. Open the environment variables**

In the sidebar, click **Applications**, then click the application. Click the **Environment variables** tab.

**2. Add the variables**

Click **Add environment variables**. Add the four variables and click **Add environment variables**. They apply with the next deployment.

## Allow uploads from the browser

Direct uploads send each file from the browser to the bucket, so large files don't pass through Puma. The browser needs the bucket's permission for that: a CORS rule.

**1. Open the bucket settings**

In the sidebar, click **Object storage**, click the bucket, then click the **Settings** tab.

**2. Create the rule**

In **CORS policy**, click **Create rule**. Under **Allowed methods**, select only `PUT`. Under **Allowed origins**, enter the application's address, for example `https://storefront-x7k2m.sevalla.app`; click **Add allowed origin** to add a custom domain too. Under **Allowed headers**, enter `Content-Type`, `Content-MD5` and `Content-Disposition`, clicking **Add allowed header** for each. Click **Create CORS rule**.

![The Create CORS rule sheet with the allowed methods, origins and headers](https://assets.sevalla.com/screenshots/light/object-storage-settings-cors-create.png)

These are the headers Active Storage sends with each direct upload. See [CORS policy](https://docs.sevalla.com/object-storage/settings.md#cors-policy).

## Configure Active Storage

Add the AWS SDK for S3, which Active Storage's S3 service uses, and create Active Storage's tables:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
bundle add aws-sdk-s3 --require=false
bin/rails active_storage:install
bin/rails db:migrate
```

Add a service for the bucket at the end of `config/storage.yml`:

```yaml config/storage.yml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
sevalla:
  service: S3
  endpoint: <%= ENV["S3_ENDPOINT"] %>
  access_key_id: <%= ENV["S3_ACCESS_KEY_ID"] %>
  secret_access_key: <%= ENV["S3_SECRET_ACCESS_KEY"] %>
  region: auto
  bucket: <%= ENV["S3_BUCKET"] %>
  request_checksum_calculation: when_required
  response_checksum_validation: when_required
```

* `region: auto` is the region of every Sevalla bucket.
* The two checksum options make the AWS SDK add its own checksum only to requests that need one. Active Storage already sends an MD5 checksum with each upload, and without the options the SDK adds a CRC32 one too, which the bucket rejects with `You can only specify one non-default checksum at a time.`

In `config/environments/production.rb`, use the service:

```ruby config/environments/production.rb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
  config.active_storage.service = :sevalla
```

Development keeps storing files on the local disk with the `local` service.

## Attach photos to products

Give each product a photo:

```ruby app/models/product.rb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
class Product < ApplicationRecord
  has_one_attached :photo
end
```

In `app/controllers/products_controller.rb`, allow the new field in `product_params`:

```ruby app/controllers/products_controller.rb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
      params.expect(product: [ :name, :description, :photo ])
```

In `app/views/products/_form.html.erb`, add a file field above the submit button. `direct_upload: true` makes the browser upload the file to the bucket before it sends the form:

```erb app/views/products/_form.html.erb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
  <div>
    <%= form.label :photo, style: "display: block" %>
    <%= form.file_field :photo, accept: "image/*", direct_upload: true %>
  </div>
```

In `app/views/products/_product.html.erb`, show the photo resized to fit 400 by 400 pixels, after the description:

```erb app/views/products/_product.html.erb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
  <% if product.photo.attached? %>
    <%= image_tag product.photo.variant(resize_to_limit: [ 400, 400 ]) %>
  <% end %>
```

Direct uploads need Active Storage's JavaScript. Pin it in `config/importmap.rb` and start it in `app/javascript/application.js`:

```ruby config/importmap.rb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pin "@rails/activestorage", to: "activestorage.esm.js"
```

```js app/javascript/application.js theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import * as ActiveStorage from "@rails/activestorage"
ActiveStorage.start()
```

Commit and push the changes, `Gemfile.lock` and `db/schema.rb` included. If **Auto-deploy** is off, click **Deploy now** on the application's **Overview** tab. The `Migrate` job creates Active Storage's tables before the new version starts.

## Verify

1. Open the application's domain, click **New product**, enter a name, choose an image in **Photo**, and click **Create Product**. The product's page shows the photo, at most 400 pixels wide.
2. In the sidebar, click **Object storage**, click the bucket, and look at the **Objects** tab: it lists two objects with random names, the upload and its resized variant. See [Manage objects](https://docs.sevalla.com/object-storage/objects.md).
3. Deploy the application again, then reload the product's page: the photo is still there, because it's in the bucket and not on the instance's disk.

The image's address is a Rails URL under `/rails/active_storage/representations/redirect/`, which redirects to a link on the bucket that expires after 5 minutes. Rails creates each variant the first time a page asks for it, and stores it in the bucket next to the original.

Active Storage also queues `ActiveStorage::AnalyzeJob` for each upload, to record an image's width and height. Run it with Solid Queue as in [Run Rails background jobs](https://docs.sevalla.com/guides/ruby/rails/background-jobs.md); variants work without it.

### Check from a terminal

These checks need no dashboard. `API`, `H`, `APP_ID`, `WEB_ID`, `APP_HOST` and `BUCKET_ID` are set as in [Deploy with the API](#deploy-with-the-api).

1. Upload a file through Active Storage and get its expiring URL, with [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process) (`execProcessCommandV3`) in the web process, the API's counterpart of the web terminal. The command runs without a shell, and the key needs write access to the application. The output also holds Active Storage's log line, so `grep` keeps the URL. Then open the URL:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   URL=$(curl -s -X POST "$API/applications/$APP_ID/processes/$WEB_ID/exec" -H "$H" -H "Content-Type: application/json" \
     -d '{"command": ["bin/rails", "runner", "puts ActiveStorage::Blob.create_and_upload!(io: StringIO.new(%q(Hello from Sevalla)), filename: %q(hello.txt)).url"], "timeout": 60}' \
     | jq -r .stdout | grep '^https://')
   curl -s "$URL"
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Hello from Sevalla
   ```

2. The file in the bucket under a random key, from [List objects](https://api-docs.sevalla.com/v3/object-storages/list-objects) (`listObjectStorageObjectsV3`):

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/object-storage/$BUCKET_ID/objects" -H "$H" | jq -c '.data[] | {key, size}'
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"key":"e0i68a8qupyqunct26fierexkr7b","size":18}
   ```

3. The CORS rule, from [List CORS policies](https://api-docs.sevalla.com/v3/object-storages/list-cors-policies) (`listObjectStorageCorsPoliciesV3`), with your domain in `origins`:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/object-storage/$BUCKET_ID/cors-policies" -H "$H" | jq -c '.data[] | {methods, origins, headers}'
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"methods":["PUT"],"origins":["https://storefront-x7k2m.sevalla.app"],"headers":["Content-Type","Content-MD5","Content-Disposition"]}
   ```

CLI: `sevalla object-storage objects list --bucket-id $BUCKET_ID` and `sevalla object-storage cors-policies list --bucket-id $BUCKET_ID`. MCP: `sevalla_api_update` with `execProcessCommandV3`, and `sevalla_api_read` with `listObjectStorageObjectsV3`.

## Deploy with a Dockerfile

The Dockerfile from [Deploy with a Dockerfile](https://docs.sevalla.com/guides/ruby/rails/deploy.md#deploy-with-a-dockerfile) needs no change: it already installs `libvips`, which the `image_processing` gem in Rails' `Gemfile` uses to create variants, and `aws-sdk-s3` is plain Ruby, which `bundle install` installs from `Gemfile.lock`. With Ingot, the build plan's **Native libraries** row shows `libvips42t64 (ruby-vips)`: Ingot installs it for the same gem.

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| The image doesn't load, and the runtime logs show `Aws::S3::Errors::InvalidRequest (You can only specify one non-default checksum at a time.)` | `config/storage.yml` lacks the two checksum options. | Add them and deploy. Variants that failed keep a record without a file: delete the records with `bin/rails runner "ActiveStorage::VariantRecord.delete_all"` in the web terminal, and Rails creates the variants again. |

## Deploy with the API

The same steps as [Create a bucket](#create-a-bucket), [Add the bucket details to the application](#add-the-bucket-details-to-the-application) and [Allow uploads from the browser](#allow-uploads-from-the-browser), as calls to the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md). `APP_ID` and `APP_HOST` are the application and its domain from [Deploy with the API](https://docs.sevalla.com/guides/ruby/rails/deploy.md#deploy-with-the-api) in the deploy guide, with `API` and `H` set as there. The API key also needs the capabilities to create, read and update object storage. Push the Active Storage changes first: the deployment builds the branch as it is.

**1. Create the bucket**

[Create object storage bucket](https://api-docs.sevalla.com/v3/object-storages/create-object-storage-bucket) (`createObjectStorageV3`) returns the bucket's name, endpoint and keys. `location` is the region hint closest to the application, here `weur` (Western Europe) for Frankfurt:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
BUCKET=$(curl -s -X POST "$API/object-storage" -H "$H" -H "Content-Type: application/json" \
  -d '{"display_name": "storefront-media", "location": "weur"}')
BUCKET_ID=$(echo "$BUCKET" | jq -r .id)
echo "$BUCKET" | jq -c '{bucket_name, endpoint}'
```

The response holds the secret key, so the command prints only the other fields. CLI: `sevalla object-storage create --name storefront-media --location weur`. MCP: `sevalla_api_create` with `createObjectStorageV3` and the same body.

**2. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per variable, from the bucket's details, for runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for pair in "S3_BUCKET=$(echo "$BUCKET" | jq -r .bucket_name)" "S3_ENDPOINT=$(echo "$BUCKET" | jq -r .endpoint)" \
  "S3_ACCESS_KEY_ID=$(echo "$BUCKET" | jq -r .access_key)" "S3_SECRET_ACCESS_KEY=$(echo "$BUCKET" | jq -r .secret_key)"; do
  curl -s -X POST "$API/applications/$APP_ID/env-vars" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --arg key "${pair%%=*}" --arg value "${pair#*=}" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}')" \
    | jq -r .key
done
```

CLI: `sevalla apps env-vars create $APP_ID --key S3_BUCKET --value <bucket name>`, once per variable, for build and runtime. MCP: `set_environment_variables` with `runtime: true` and `buildtime: false`.

**3. Create the CORS rule**

[Create CORS policy](https://api-docs.sevalla.com/v3/object-storages/create-cors-policy) (`createObjectStorageCorsPolicyV3`) adds the rule for direct uploads:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "$API/object-storage/$BUCKET_ID/cors-policies" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg origin "https://$APP_HOST" \
    '{methods: ["PUT"], origins: [$origin], headers: ["Content-Type", "Content-MD5", "Content-Disposition"]}')" \
  | jq -r .message
```

CLI: `sevalla object-storage cors-policies create $BUCKET_ID --origins https://$APP_HOST --methods PUT --headers Content-Type,Content-MD5,Content-Disposition`. MCP: `sevalla_api_update` with `createObjectStorageCorsPolicyV3` and the same body.

**4. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" -H "$H" -H "Content-Type: application/json" \
  -d '{}' | jq -r .id)
```

CLI: `sevalla apps deployments trigger $APP_ID`. MCP: `trigger_deployment`.

**5. Wait for the deployment**

Poll [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until the status is final: `success`, `failed`, `cancelled` or `skipped`. The loop keeps polling through a failed request and stops after 30 minutes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "$H" | jq -r '.status // empty')
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

CLI: `sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment`.

Then run the checks in [Check from a terminal](#check-from-a-terminal).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.