Skip to content

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.

SettingValue
Endpointhttps://s3.hoststack.dev
Regionhel1
AddressingPath-style only: https://s3.hoststack.dev/<bucket>/<key>
Bucket nameGenerated: hs-<teamId>-<8 hex>
CredentialsA 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

  1. 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.

  2. 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:

bash
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.

VariableValue
S3_ENDPOINThttps://s3.hoststack.dev
S3_REGIONhel1
S3_BUCKETThe bucket name
S3_ACCESS_KEY_IDThe bucket's access key (HSOBJ…)
S3_SECRET_ACCESS_KEYThe 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();

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' } });

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:

text
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=2 works for cache-busting.

Caching

Cache-Control: public, max-age=300 unless the object was uploaded with its own — set CacheControl on upload to control it. Range requests and conditional requests (If-None-Match, If-Modified-Since → 304) work.

CORS

Public 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 inlineDownloaded
Images: PNG, JPEG, GIF, WebP, AVIF, APNG, BMP, ICO, HEIC/HEIF, TIFFHTML, SVG, XML
All video/* and audio/*JavaScript
PDFObjects with no or an unknown Content-Type
Fonts: WOFF, WOFF2, TTF, OTF, EOTAny 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 SlowDown with a Retry-After header.
  • 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 Upload from @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.

Multipart upload with aws-sdk v3
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 — ?acl returns 403, and x-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) or addressing_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-read ACL 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?