Object Storage
S3-compatible buckets for uploads, media and backups. Link a bucket to a service and any S3 client works with it — set path-style addressing and point it at HostStack's endpoint.
Overview
A managed bucket is stored in Hetzner Object Storage in Helsinki (hel1) and reached through HostStack's S3 gateway. Each bucket has its own access key, scoped to that one bucket, so a leaked key exposes nothing else your team owns.
| Setting | Value |
|---|---|
| Endpoint | https://s3.hoststack.dev |
| Region | hel1 |
| Addressing | Path-style only: https://s3.hoststack.dev/<bucket>/<key> |
| Bucket name | Generated: hs-<teamId>-<8 hex> |
| Credentials | A HostStack-issued access key (HSOBJ…) and secret per bucket |
Buckets are billed per GB stored and per GB of egress (downloads) — see Billing. For files that only one container reads and writes, a persistent volume is simpler; use object storage when several services, browsers or replicas need the same files.
Create a bucket
Create it in the dashboard
Open Object Storage and click New Bucket. The name is generated for you. You can turn on Public read here or later.
Find its credentials
The bucket page shows the endpoint, bucket name, access key and secret to team admins. Rotate credentials issues a new key pair; the old key stops working immediately, so redeploy the services that use it.
Buckets are created and deleted only in the dashboard — the S3 API's CreateBucket and DeleteBucket are not supported.
Connect a service
Link the bucket to a service and HostStack injects its connection details as environment variables. In the dashboard, open the service, then Connections → Connect existing, pick the bucket and keep or change the alias. Or from the terminal:
hoststack services link svc_... --type object_storage --resource <bucket-id> --alias S3
hoststack deploy trigger svc_...The alias (default S3) is the prefix of every variable, so two buckets on one service get two aliases — MEDIA_BUCKET and BACKUPS_BUCKET, for example. Variables apply on the next deploy.
| Variable | Value |
|---|---|
| S3_ENDPOINT | https://s3.hoststack.dev |
| S3_REGION | hel1 |
| S3_BUCKET | The bucket name |
| S3_ACCESS_KEY_ID | The bucket's access key (HSOBJ…) |
| S3_SECRET_ACCESS_KEY | The bucket's secret key |
Client configuration
Every client needs three things: the endpoint, the region from S3_REGION, and path-style addressing. Virtual-host addresses (<bucket>.s3.hoststack.dev) do not resolve, and most SDKs use them by default.
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
forcePathStyle: true, // required
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
});
await s3.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: 'avatars/42.png',
Body: buffer,
ContentType: 'image/png',
}),
);
const res = await s3.send(
new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: 'avatars/42.png' }),
);
const bytes = await res.Body!.transformToByteArray();const AWS = require('aws-sdk');
const s3 = new AWS.S3({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
s3ForcePathStyle: true, // required
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
});import os
import boto3
from botocore.config import Config
s3 = boto3.client(
"s3",
endpoint_url=os.environ["S3_ENDPOINT"],
region_name=os.environ["S3_REGION"],
aws_access_key_id=os.environ["S3_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["S3_SECRET_ACCESS_KEY"],
config=Config(s3={"addressing_style": "path"}), # required
)
s3.upload_file("report.pdf", os.environ["S3_BUCKET"], "reports/report.pdf")export AWS_ACCESS_KEY_ID=$S3_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY=$S3_SECRET_ACCESS_KEY
export AWS_DEFAULT_REGION=hel1
aws configure set default.s3.addressing_style path
aws s3 cp file.zip s3://$S3_BUCKET/ --endpoint-url https://s3.hoststack.dev
aws s3 ls s3://$S3_BUCKET/ --endpoint-url https://s3.hoststack.dev// config/plugins.js — npm install @strapi/provider-upload-aws-s3
module.exports = ({ env }) => ({
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
baseUrl: `https://s3.hoststack.dev/${env('S3_BUCKET')}`,
s3Options: {
endpoint: env('S3_ENDPOINT'),
region: env('S3_REGION'),
forcePathStyle: true,
credentials: {
accessKeyId: env('S3_ACCESS_KEY_ID'),
secretAccessKey: env('S3_SECRET_ACCESS_KEY'),
},
params: {
Bucket: env('S3_BUCKET'),
// ACL: 'public-read' is accepted, but only the bucket's
// Public read switch makes objects publicly readable.
},
},
},
},
},
});For Strapi, baseUrl is what ends up in the media URLs it stores, so those URLs only load for visitors when the bucket has Public read on.
Presigned URLs
A presigned URL lets a browser download or upload one object without your credentials. GET and PUT are supported, signed in the query string (SigV4). X-Amz-Expires can be up to 7 days, as on S3.
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Download link, valid for an hour
const downloadUrl = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: 'invoices/1001.pdf' }),
{ expiresIn: 3600 },
);
// Browser upload: hand this to the client, which PUTs the file to it
const uploadUrl = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: `uploads/${crypto.randomUUID()}.jpg`,
ContentType: 'image/jpeg',
}),
{ expiresIn: 600 },
);
// In the browser:
// await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'image/jpeg' } });download_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": os.environ["S3_BUCKET"], "Key": "invoices/1001.pdf"},
ExpiresIn=3600,
)
upload_url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": os.environ["S3_BUCKET"], "Key": "uploads/photo.jpg"},
ExpiresIn=600,
)Requests signed in headers (what an SDK sends) must be made within 15 minutes of the server clock, so a container with a drifting clock gets 403 responses. Presigned download links follow the same inline-or-download rules as public URLs.
Public read
Each bucket has a Public read switch on its page (and in the New Bucket dialog). It is off by default. When it is on, anyone can fetch an object without credentials:
https://s3.hoststack.dev/<bucket>/<key>Only reading individual objects (GET and HEAD) becomes public. Listing the bucket is never public, and writes and deletes still need the bucket's key.
- Query strings
Ignored on public URLs, so
?v=2works for cache-busting.CachingCache-Control: public, max-age=300unless the object was uploaded with its own — setCacheControlon upload to control it. Range requests and conditional requests (If-None-Match,If-Modified-Since→304) work.CORSPublic reads are served with
Access-Control-Allow-Origin: *.
Inline or download
Public URLs and presigned download links display only a fixed set of content types in the browser. Everything else is served with Content-Disposition: attachment and Content-Security-Policy: default-src 'none'; sandbox, so opening it downloads the file instead of rendering it. Every response carries X-Content-Type-Options: nosniff.
| Shown inline | Downloaded |
|---|---|
| Images: PNG, JPEG, GIF, WebP, AVIF, APNG, BMP, ICO, HEIC/HEIF, TIFF | HTML, SVG, XML |
| All video/* and audio/* | JavaScript |
| Objects with no or an unknown Content-Type | |
| Fonts: WOFF, WOFF2, TTF, OTF, EOT | Any other type |
| text/css, text/plain, application/json |
Embedding is unaffected: <img src=".../logo.svg"> and <script src=".../app.js"> on your own site load normally — attachment only changes what happens when the URL is opened directly in a tab. Set the right ContentType on upload, or images and PDFs download too.
Who can turn it on
Team admins and owners. The team needs a payment method and at least one settled payment, and must not be suspended or overdue — otherwise the switch says which of these is missing. Every change is recorded in the team's activity log.
Limits and billing
- Anonymous traffic has per-visitor and per-bucket request-rate limits, and a daily anonymous download cap per bucket — 100 GB/day by default; contact support for more.
- Over a limit, the response is
503 SlowDownwith aRetry-Afterheader. - Public downloads count as the bucket's egress and are billed like any other download.
- Public serving stops immediately if the team is suspended, and HostStack may turn off public read on a bucket that serves abusive content — see the Acceptable Use Policy.
public-read ACLs on a private bucket
If your app uploads with x-amz-acl: public-read (ACL: 'public-read' in the SDK) to a bucket whose Public read switch is off, the upload succeeds but the object stays private. The response carries an x-hoststack-acl-ignored header and the bucket page shows a notice. Turn on Public read to serve those objects.
Uploads and limits
- Single PUT
Up to 256 MB per request through the gateway. Larger objects need a multipart upload: the AWS CLI and boto3 switch to it automatically, and in JavaScript use
Uploadfrom@aws-sdk/lib-storage.- Object browser
The bucket page in the dashboard browses folders (prefixes) page by page, uploads, downloads through a short-lived presigned link, deletes after confirmation, and copies an object's public URL when the bucket is public. Its uploads are single PUTs, so 256 MB at most.
import { Upload } from '@aws-sdk/lib-storage';
await new Upload({
client: s3,
params: { Bucket: process.env.S3_BUCKET, Key: 'videos/talk.mp4', Body: stream },
}).done();Unsupported operations
Object reads, writes, deletes, copies, listing and multipart uploads work. These do not, and bucket configuration calls return 403 AccessDenied:
- Virtual-host addressing (
<bucket>.s3.hoststack.dev) - ACLs and grants —
?aclreturns 403, andx-amz-acl/x-amz-grant-*headers are ignored; use the Public read switch - Bucket policies
- CORS, website, lifecycle, versioning, object lock, replication, notification, tagging and encryption configuration
- Creating or deleting buckets through the S3 API — use the dashboard
Troubleshooting
- 403 SignatureDoesNotMatch
The client is using virtual-host addressing — set
forcePathStyle: true(aws-sdk v3),s3ForcePathStyle(v2) oraddressing_style: 'path'(boto3). If it already does, check the container's clock: header-signed requests more than 15 minutes off are rejected. Also check the key was not rotated since the last deploy.- 403 on a public URL
The bucket's Public read switch is off, or it was turned off. A
public-readACL on upload does not make an object public by itself. Listing the bucket (/<bucket>/) is always 403 without credentials.- A public file downloads instead of displaying
Its content type is not on the inline list, or it was uploaded without a
Content-Type. See Inline or download.- 503 SlowDown
A public-read rate limit or the daily anonymous download cap was hit. Wait for
Retry-After, put a CDN or your own cache in front, or contact support to raise the cap.
Next steps
Was this page helpful?