K

Connect storage

Declare two lines in axhub.yaml and your app gets its own S3-compatible locker. How to upload and download files with standard S3 tooling.

Files like images and attachments don't go in the database — they go in an app-scoped locker (a bucket).
Create the locker with a two-line declaration, then upload and download files from code.

The locker is compatible with S3, a widely used standard. So you use standard S3 tooling as is@aws-sdk/client-s3, boto3 — and files travel directly between your app and the locker, so there's no size cap.

What you need

  • An app with a server — this doesn't attach to UI-only static hosting apps (see Good to know below)
  • axhub.yaml in your GitHub repository

Unlike the database, templates don't ship any storage code. You add the declaration and the code below yourself.

Two lines to declare

Add the following to axhub.yaml and deploy — that's it.

axhub.yaml
storage:
  enabled: true

On the next deploy, an app-scoped locker is created and four access values are auto-injected into the app's runtime as environment variables.

Environment variableValue
STORAGE_ENDPOINTThe locker's address (S3-compatible endpoint)
STORAGE_BUCKETThe app-scoped bucket name
STORAGE_ACCESS_KEYAccess key
STORAGE_SECRET_KEYSecret key

To turn it on and check it from the terminal:

axhub apps storage enable --app demo --execute
axhub apps storage status --app demo

There is one bucket per app, and production (live) and staging (testing) share it. If you want to separate files by environment, use a key prefix in front of file names, like staging/….

Uploading

Read the injected environment variables to build an S3 client, and you're done.

The checksum setting (WHEN_REQUIRED) is required. The checksum that recent S3 SDKs attach by default conflicts with this locker, so without it uploads fail with SignatureDoesNotMatch. The examples below already include it.

Node — @aws-sdk/client-s3
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({
  endpoint: process.env.STORAGE_ENDPOINT,
  region: 'auto',
  credentials: {
    accessKeyId: process.env.STORAGE_ACCESS_KEY!,
    secretAccessKey: process.env.STORAGE_SECRET_KEY!,
  },
  // Required — without these, uploads fail with SignatureDoesNotMatch
  requestChecksumCalculation: 'WHEN_REQUIRED',
  responseChecksumValidation: 'WHEN_REQUIRED',
});

await s3.send(new PutObjectCommand({
  Bucket: process.env.STORAGE_BUCKET,
  Key: 'uploads/photo.png',
  Body: buffer,
  ContentType: 'image/png',
}));
Python — boto3
import os, boto3
from botocore.config import Config

s3 = boto3.client(
    "s3",
    endpoint_url=os.environ["STORAGE_ENDPOINT"],
    region_name="auto",
    aws_access_key_id=os.environ["STORAGE_ACCESS_KEY"],
    aws_secret_access_key=os.environ["STORAGE_SECRET_KEY"],
    # Required — without this, uploads fail with SignatureDoesNotMatch
    config=Config(
        request_checksum_calculation="when_required",
        response_checksum_validation="when_required",
    ),
)

s3.put_object(
    Bucket=os.environ["STORAGE_BUCKET"],
    Key="uploads/photo.png",
    Body=data,
    ContentType="image/png",
)

Serving files to users

Don't stream files through your app server — generate a presigned URL (a temporary address that only works for a set time) and hand it to the browser. The file bytes travel directly between browser and locker, so your app server carries none of it.

Node
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const url = await getSignedUrl(
  s3,
  new GetObjectCommand({ Bucket: process.env.STORAGE_BUCKET, Key: 'uploads/photo.png' }),
  { expiresIn: 900 },  // 15 minutes
);
Python
url = s3.generate_presigned_url(
    "get_object",
    Params={"Bucket": os.environ["STORAGE_BUCKET"], "Key": "uploads/photo.png"},
    ExpiresIn=900,
)

The bucket is private, so without one of these addresses nothing outside can reach it.

Viewing files from the console or terminal

In the app console → Resources tab → Storage, you can browse uploaded files like folders, get a download link (valid for 15 minutes), or delete files. The four access values are copyable there too. Uploading happens from app code, not the console.

If the app owner has locked the app with a viewing policy, even admins may be blocked from viewing files in the console.

From the terminal, it looks like this.

axhub apps storage ls --app demo                          # list files
axhub apps storage get-url uploads/photo.png --app demo   # issue a download link
axhub apps storage rm uploads/photo.png --app demo --execute

Limits and errors

ItemValue
Buckets per app1 (shared between production and staging)
File size / upload limitNo platform limit (direct communication with the locker)
Presigned URL lifetime15 minutes (fixed)
Plan storage quotaSeat-based (plan) tiers Free 1 GB · Pro 50 GB · Business 200 GB · Enterprise 500 GB — combined with DB usage, no blocking on overage. Pay-as-you-go has no plan quota and bills by usage

Errors you'll run into most often.

SituationResponse
Uploading without the checksum settingSignatureDoesNotMatch — add the WHEN_REQUIRED options above
Calling the file API before the bucket exists404 — "deploy first"
Trying to enable it on a UI-only (static) app409 unsupported_for_static_app
A static app deploys with the declaration in placeDeploy fails with prepare.storage_unsupported_for_static
Bucket creation (provisioning) failsDeploy fails with prepare.storage_provision_failed — redeploy to retry

When uploading with the aws CLI (v2.31 or later), set AWS_REQUEST_CHECKSUM_CALCULATION=when_required for the same reason.

Good to know

  • It doesn't attach to UI-only apps — a static hosting app has no always-running server, so there's nowhere to inject the access values. Deploying with the declaration in place fails clearly.
  • Removing the declaration doesn't delete the files — the bucket, files, and access values all stay. They're only deleted when the app itself is permanently deleted. Suspending or archiving the app preserves the files too.
  • It isn't injected retroactively into already-running apps — the environment variables arrive starting with the first deploy after you turn it on.
  • You can't override STORAGE_* yourself — an environment variable you save under the same name is ignored.
  • Isolation is enforced by cloud IAM (access permission management) — an app's keys only work on its own bucket, and public access to the bucket is blocked, so nothing outside can read it without a presigned URL.
  • The very first write right after enabling can occasionally fail once (permissions take tens of seconds to propagate) — just retry.

You've succeeded when

After the post-declaration deploy finishes, a file uploaded by your app code appears in the app console → Resources tab → Storage — that's success.

Next, read who is currently connected in Read the SSO user info.