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.yamlin 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.
storage:
enabled: trueOn 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 variable | Value |
|---|---|
STORAGE_ENDPOINT | The locker's address (S3-compatible endpoint) |
STORAGE_BUCKET | The app-scoped bucket name |
STORAGE_ACCESS_KEY | Access key |
STORAGE_SECRET_KEY | Secret key |
To turn it on and check it from the terminal:
axhub apps storage enable --app demo --execute
axhub apps storage status --app demoThere 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.
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',
}));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.
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
);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 --executeLimits and errors
| Item | Value |
|---|---|
| Buckets per app | 1 (shared between production and staging) |
| File size / upload limit | No platform limit (direct communication with the locker) |
| Presigned URL lifetime | 15 minutes (fixed) |
| Plan storage quota | Seat-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.
| Situation | Response |
|---|---|
| Uploading without the checksum setting | SignatureDoesNotMatch — add the WHEN_REQUIRED options above |
| Calling the file API before the bucket exists | 404 — "deploy first" |
| Trying to enable it on a UI-only (static) app | 409 unsupported_for_static_app |
| A static app deploys with the declaration in place | Deploy fails with prepare.storage_unsupported_for_static |
| Bucket creation (provisioning) fails | Deploy 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.