Object Storage

Zoen stores every uploaded file — knowledge base documents, chat attachments, execution outputs, profile pictures, and more — in object storage. Four backends are supported:

BackendWhen to use
Local diskSingle-node Docker, local development, evaluation
AWS S3Production, especially when running more than one app replica
Azure BlobProduction on Azure
Google Cloud StorageProduction on GCP

Local disk writes to the container's /uploads directory. Files are lost when the container is recreated unless that path is on a persistent volume, and they are not shared across replicas. For any multi-replica or production deployment, use S3, Azure Blob, or Google Cloud Storage.

How the backend is selected

Zoen picks the backend automatically from environment variables — there is no explicit "provider" flag. The logic, in order of precedence:

  1. Azure Blob — used if AZURE_STORAGE_CONTAINER_NAME is set and either (AZURE_ACCOUNT_NAME + AZURE_ACCOUNT_KEY) or AZURE_CONNECTION_STRING is set.
  2. AWS S3 — used if S3_BUCKET_NAME and AWS_REGION are set (and Azure is not configured).
  3. Google Cloud Storage — used if GCS_BUCKET_NAME is set (and neither Azure nor S3 is configured).
  4. Local disk — the fallback when none is configured.

If more than one backend is configured, the first match in that order wins. Set only the variables for the backend you intend to use.

Set up AWS S3

Create the buckets

Zoen separates files into purpose-specific buckets. At minimum you need the general workspace bucket; the rest are created on demand based on which env vars you set. A bucket that isn't configured falls back to the general bucket where the code allows it, but the recommended setup is one bucket per purpose.

# Set your region once
export AWS_REGION=us-east-1

# Create buckets (names must be globally unique — prefix with your org)
for name in workspace-files knowledge-base execution-files chat-files \
            copilot-files profile-pictures og-images workspace-logos; do
  aws s3api create-bucket \
    --bucket "myorg-sim-$name" \
    --region "$AWS_REGION" \
    --create-bucket-configuration LocationConstraint="$AWS_REGION"
done

In us-east-1, omit the --create-bucket-configuration flag — that region rejects an explicit LocationConstraint.

Keep all buckets private (block public access). Zoen serves files through short-lived presigned URLs, so the buckets never need public read access.

Grant access with an IAM policy

Create an IAM policy scoped to your buckets and attach it to the user (or role) Zoen runs as:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::myorg-sim-*",
        "arn:aws:s3:::myorg-sim-*/*"
      ]
    }
  ]
}

You then have two ways to supply credentials:

  • Static keys — create an IAM user with this policy and set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.
  • Instance/role credentials (recommended) — attach the policy to the EC2 instance role, ECS task role, or EKS IRSA role. Leave AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY unset and Zoen falls back to the default AWS credential chain automatically.

Configure environment variables

Set the region, optionally the credentials, and the bucket names:

# Region + credentials
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIA...          # omit when using an instance/IRSA role
AWS_SECRET_ACCESS_KEY=...          # omit when using an instance/IRSA role

# Buckets (per purpose)
S3_BUCKET_NAME=myorg-sim-workspace-files
S3_KB_BUCKET_NAME=myorg-sim-knowledge-base
S3_EXECUTION_FILES_BUCKET_NAME=myorg-sim-execution-files
S3_CHAT_BUCKET_NAME=myorg-sim-chat-files
S3_COPILOT_BUCKET_NAME=myorg-sim-copilot-files
S3_PROFILE_PICTURES_BUCKET_NAME=myorg-sim-profile-pictures
S3_OG_IMAGES_BUCKET_NAME=myorg-sim-og-images
S3_WORKSPACE_LOGOS_BUCKET_NAME=myorg-sim-workspace-logos

Only AWS_REGION and S3_BUCKET_NAME are strictly required to switch Zoen into S3 mode. Add the others so each file type lands in its own bucket.

S3 bucket reference

VariableStoresRequired
AWS_REGIONRegion for all bucketsYes (enables S3)
AWS_ACCESS_KEY_IDAccess keyNo (uses credential chain if unset)
AWS_SECRET_ACCESS_KEYSecret keyNo (uses credential chain if unset)
S3_BUCKET_NAMEGeneral workspace filesYes (enables S3)
S3_KB_BUCKET_NAMEKnowledge base documentsRecommended
S3_EXECUTION_FILES_BUCKET_NAMEWorkflow execution files (default: sim-execution-files)Recommended
S3_CHAT_BUCKET_NAMEDeployed chat assetsRecommended
S3_COPILOT_BUCKET_NAMECopilot attachmentsRecommended
S3_PROFILE_PICTURES_BUCKET_NAMEUser avatarsRecommended
S3_OG_IMAGES_BUCKET_NAMEOpenGraph preview images (falls back to S3_BUCKET_NAME)Optional
S3_WORKSPACE_LOGOS_BUCKET_NAMEWorkspace logos (falls back to S3_BUCKET_NAME)Optional
S3_LOGS_BUCKET_NAMEStored logsOptional
S3_ENDPOINTCustom endpoint for S3-compatible storage (R2, MinIO, B2)Optional (AWS S3 if unset)
S3_FORCE_PATH_STYLEtrue for path-style addressing (MinIO/Ceph)Optional (defaults false)

Apply the configuration

Add the storage variables to the .env file used by docker-compose.prod.yml, then restart:

docker compose -f docker-compose.prod.yml up -d

Because files now live in S3, you no longer depend on a local /uploads volume for durability.

Set the variables under app.env (non-secret, e.g. region and bucket names) and supply credentials through a secret. The chart ships a complete example at helm/sim/examples/values-aws.yaml:

app:
  env:
    AWS_REGION: "us-east-1"
    S3_BUCKET_NAME: "myorg-sim-workspace-files"
    S3_KB_BUCKET_NAME: "myorg-sim-knowledge-base"
    S3_EXECUTION_FILES_BUCKET_NAME: "myorg-sim-execution-files"
    # ...remaining buckets

On EKS, prefer IRSA: attach the IAM policy to the service account's role and leave the access-key variables unset.

Set up Azure Blob

Azure Blob uses one container per purpose, mirroring the S3 layout. Authenticate with either a connection string or an account name + key.

# Credentials — provide ONE of these forms
AZURE_ACCOUNT_NAME=mystorageaccount
AZURE_ACCOUNT_KEY=...
# or
AZURE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net

# Containers (per purpose)
AZURE_STORAGE_CONTAINER_NAME=workspace-files
AZURE_STORAGE_KB_CONTAINER_NAME=knowledge-base
AZURE_STORAGE_EXECUTION_FILES_CONTAINER_NAME=execution-files
AZURE_STORAGE_CHAT_CONTAINER_NAME=chat-files
AZURE_STORAGE_COPILOT_CONTAINER_NAME=copilot-files
AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME=profile-pictures
AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME=og-images
AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME=workspace-logos

A full Helm example lives at helm/sim/examples/values-azure.yaml.

Set up Google Cloud Storage

Create the buckets

GCS uses one bucket per purpose, mirroring the S3 layout:

export PROJECT_ID=your-project-id
export LOCATION=us-central1

# Create buckets (names must be globally unique — prefix with your org)
for name in workspace-files knowledge-base execution-files chat-files \
            copilot-files profile-pictures og-images workspace-logos; do
  gcloud storage buckets create "gs://myorg-sim-$name" \
    --project "$PROJECT_ID" \
    --location "$LOCATION" \
    --uniform-bucket-level-access
done

Keep all buckets private (no allUsers bindings). Zoen serves files through short-lived V4 signed URLs, so the buckets never need public read access.

Because uploads are sent directly from the browser via signed PUT requests, each bucket needs a CORS policy that allows your Zoen origin:

cat > /tmp/cors.json <<'EOF'
[
  {
    "origin": ["https://your-sim-domain.com"],
    "method": ["GET", "PUT"],
    "responseHeader": [
      "Content-Type",
      "ETag",
      "x-goog-meta-originalname",
      "x-goog-meta-uploadedat",
      "x-goog-meta-purpose",
      "x-goog-meta-userid",
      "x-goog-meta-workspaceid",
      "x-goog-meta-workflowid",
      "x-goog-meta-executionid"
    ],
    "maxAgeSeconds": 3600
  }
]
EOF

for name in workspace-files knowledge-base execution-files chat-files \
            copilot-files profile-pictures og-images workspace-logos; do
  gcloud storage buckets update "gs://myorg-sim-$name" --cors-file=/tmp/cors.json
done

Header names must be listed individually — GCS CORS matches responseHeader entries exactly and does not support wildcards like x-goog-meta-*. ETag is required because large-file multipart uploads read each part's ETag from the browser, and CORS hides the header otherwise.

Grant access

Create a service account (or reuse the one your workload runs as) and grant it object access on the buckets:

gcloud iam service-accounts create sim-storage --project "$PROJECT_ID"

for name in workspace-files knowledge-base execution-files chat-files \
            copilot-files profile-pictures og-images workspace-logos; do
  gcloud storage buckets add-iam-policy-binding "gs://myorg-sim-$name" \
    --member "serviceAccount:sim-storage@$PROJECT_ID.iam.gserviceaccount.com" \
    --role roles/storage.objectAdmin
done

You then have two ways to supply credentials:

  • Application Default Credentials (recommended on GCP) — run Zoen with the service account via GKE Workload Identity (or attach it to the GCE instance) and leave GCS_CREDENTIALS_JSON unset. Because there is no private key in this mode, generating signed URLs uses the IAM signBlob API — grant the service account roles/iam.serviceAccountTokenCreator on itself:

    gcloud iam service-accounts add-iam-policy-binding \
      "sim-storage@$PROJECT_ID.iam.gserviceaccount.com" \
      --member "serviceAccount:sim-storage@$PROJECT_ID.iam.gserviceaccount.com" \
      --role roles/iam.serviceAccountTokenCreator
  • Inline key (for Docker Compose or non-GCP hosts) — create a JSON key for the service account and set GCS_CREDENTIALS_JSON to its contents. With a private key present, signed URLs are generated locally and no extra IAM role is needed.

Configure environment variables

# Credentials — omit both when using Workload Identity / ADC
GCS_PROJECT_ID=your-project-id            # optional; inferred from credentials when unset
GCS_CREDENTIALS_JSON='{"type":"service_account","client_email":"...","private_key":"..."}'

# Buckets (per purpose)
GCS_BUCKET_NAME=myorg-sim-workspace-files
GCS_KB_BUCKET_NAME=myorg-sim-knowledge-base
GCS_EXECUTION_FILES_BUCKET_NAME=myorg-sim-execution-files
GCS_CHAT_BUCKET_NAME=myorg-sim-chat-files
GCS_COPILOT_BUCKET_NAME=myorg-sim-copilot-files
GCS_PROFILE_PICTURES_BUCKET_NAME=myorg-sim-profile-pictures
GCS_OG_IMAGES_BUCKET_NAME=myorg-sim-og-images
GCS_WORKSPACE_LOGOS_BUCKET_NAME=myorg-sim-workspace-logos

Only GCS_BUCKET_NAME is strictly required to switch Zoen into GCS mode. Every purpose-specific bucket falls back to the general bucket when unset — add the others so each file type lands in its own bucket.

GCS bucket reference

VariableStoresRequired
GCS_BUCKET_NAMEGeneral workspace filesYes (enables GCS)
GCS_PROJECT_IDGCP project IDNo (inferred from credentials/ADC)
GCS_CREDENTIALS_JSONInline service-account JSONNo (uses Application Default Credentials if unset)
GCS_KB_BUCKET_NAMEKnowledge base documentsRecommended (falls back to GCS_BUCKET_NAME)
GCS_EXECUTION_FILES_BUCKET_NAMEWorkflow execution filesRecommended (falls back to GCS_BUCKET_NAME)
GCS_CHAT_BUCKET_NAMEDeployed chat assetsRecommended (falls back to GCS_BUCKET_NAME)
GCS_COPILOT_BUCKET_NAMECopilot attachmentsRecommended (falls back to GCS_BUCKET_NAME)
GCS_PROFILE_PICTURES_BUCKET_NAMEUser avatarsRecommended (falls back to GCS_BUCKET_NAME)
GCS_OG_IMAGES_BUCKET_NAMEOpenGraph preview images (falls back to GCS_BUCKET_NAME)Optional
GCS_WORKSPACE_LOGOS_BUCKET_NAMEWorkspace logos (falls back to GCS_BUCKET_NAME)Optional

A full Helm example (Workload Identity, GKE) lives at helm/sim/examples/values-gcp.yaml.

Set up an S3-compatible provider (R2, MinIO, B2)

Zoen works with any S3-compatible store by pointing the S3 client at a custom endpoint. Configure it exactly like AWS S3 (buckets, access key, secret), then add S3_ENDPOINT — and S3_FORCE_PATH_STYLE where the provider requires path-style addressing. Verified with Cloudflare R2, MinIO, Backblaze B2, and RustFS.

S3_ENDPOINT is trusted operator configuration, so it is used as-is — http:// and private hosts are accepted (no SSRF/HTTPS gate). Don't wire it to untrusted input.

The endpoint must be reachable from your users' browsers, and the bucket needs CORS. Uploads use presigned PUT requests sent directly from the browser to S3_ENDPOINT (downloads are proxied back through the app, so they only need server-side reachability). This means:

  • A purely internal endpoint (e.g. https://minio.internal:9000 that only the app pods can resolve) will let the server start cleanly but uploads will fail in the browser. Use an endpoint your users can reach.
  • Configure a CORS policy on the bucket that allows your Zoen origin (PUT, GET, and the Authorization / Content-Type / x-amz-* headers). This applies to AWS S3 too — R2 and MinIO are no different.

Cloudflare R2 uses virtual-hosted style (the default) and the region auto:

AWS_REGION=auto
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
AWS_ACCESS_KEY_ID=<r2-access-key-id>
AWS_SECRET_ACCESS_KEY=<r2-secret-access-key>
S3_BUCKET_NAME=myorg-sim-workspace-files
# ...remaining S3_*_BUCKET_NAME vars, one R2 bucket each

Leave S3_FORCE_PATH_STYLE unset — R2 supports the default virtual-hosted addressing.

MinIO (and Ceph RGW) need path-style addressing and accept any region string:

AWS_REGION=us-east-1
S3_ENDPOINT=https://minio.example.com   # must be reachable from users' browsers, not app-pods-only
S3_FORCE_PATH_STYLE=true
AWS_ACCESS_KEY_ID=<minio-access-key>
AWS_SECRET_ACCESS_KEY=<minio-secret-key>
S3_BUCKET_NAME=myorg-sim-workspace-files
# ...remaining S3_*_BUCKET_NAME vars, one bucket each

http:// works server-side, but since the browser uploads directly to this endpoint, prefer a TLS endpoint your users can reach (a mixed-content http:// target will be blocked on an https:// Zoen origin).

RustFS is a Rust-based, S3-compatible store (a MinIO drop-in). Configure it exactly like MinIO — path-style, any region string, SigV4 access key/secret:

AWS_REGION=us-east-1
S3_ENDPOINT=https://rustfs.example.com   # must be reachable from users' browsers
S3_FORCE_PATH_STYLE=true
AWS_ACCESS_KEY_ID=<rustfs-access-key>
AWS_SECRET_ACCESS_KEY=<rustfs-secret-key>
S3_BUCKET_NAME=myorg-sim-workspace-files
# ...remaining S3_*_BUCKET_NAME vars, one bucket each

The same browser-reachability and CORS requirements apply.

Verify it works

After restarting with the new configuration:

  1. Open the app and upload a document to a knowledge base (or set a profile picture).
  2. Confirm an object appears in the corresponding bucket/container.
  3. Reload the page — the file should still render (downloads stream back through the app at /api/files/serve).

If uploads fail, check the app logs for credential or permission errors (see Troubleshooting).

Common Questions

Zoen falls back to local disk, writing files to the /uploads directory inside the app container. This is fine for evaluation but not durable across container recreation and not shared across replicas — use S3, Azure Blob, or Google Cloud Storage for production.
No. Only AWS_REGION and S3_BUCKET_NAME are required to enable S3 mode. The purpose-specific buckets are recommended so each file type is isolated; og-images and workspace-logos fall back to the general bucket if their variables are unset.
On EC2/ECS/EKS, attach the IAM policy to the instance role, task role, or IRSA service-account role and leave AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY unset. Zoen resolves credentials through the default AWS SDK provider chain automatically.
No. Zoen selects a single backend, in the order Azure Blob, then S3, then Google Cloud Storage. Set only the variables for the backend you want.
Run Zoen with GKE Workload Identity (or an attached GCE service account) and leave GCS_CREDENTIALS_JSON unset — credentials resolve through Application Default Credentials. Grant the service account roles/iam.serviceAccountTokenCreator on itself so signed URLs can be generated via the IAM signBlob API.
No, and they should not be. Keep them private with public access blocked. Zoen serves files to users through short-lived presigned URLs, so the buckets never need public read permissions.
Yes. Configure it like AWS S3, then set S3_ENDPOINT to your provider's endpoint. For R2, set AWS_REGION=auto and leave S3_FORCE_PATH_STYLE unset. For MinIO/Ceph, set S3_FORCE_PATH_STYLE=true. See the S3-compatible provider section above.

On this page