Object storage (S3, GCS, Azure)#
Issue attachments and user avatars are not stored in MongoDB — they live in
object storage. Hinata supports two backends, selected with
HINATA_STORAGE_PROVIDER:
s3(default) — any S3-compatible store: the bundled MinIO, AWS S3, Google Cloud Storage (S3-interoperable XML API), Cloudflare R2, DigitalOcean Spaces, Backblaze B2, Wasabi, Ceph, a managed MinIO, …azure— Azure Blob Storage through its native API (Azure does not speak the S3 protocol).
This page covers the default setup, how downloads stay secure, and how to swap in an external provider.
MinIO in the default stack#
The production docker-compose.yml runs a MinIO container alongside the server.
It is attached to the compose local-storage profile, which is on by default
(COMPOSE_PROFILES=local-storage in .env.example):
minio:
image: minio/minio:latest
profiles: [local-storage]
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-}
volumes:
- minio-data:/data
The server connects to it over the internal Docker network, reusing the MinIO root credentials as its S3 access/secret keys:
HINATA_S3_ENDPOINT: ${HINATA_S3_ENDPOINT:-http://minio:9000}
HINATA_S3_ACCESS_KEY: ${HINATA_S3_ACCESS_KEY:-${MINIO_ROOT_USER:-}}
HINATA_S3_SECRET_KEY: ${HINATA_S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-}}
HINATA_S3_BUCKET: ${HINATA_S3_BUCKET:-hinata}
So in the default stack you only set four things in .env:
COMPOSE_PROFILES=local-storage
MINIO_ROOT_USER=hinata
MINIO_ROOT_PASSWORD=change-me-to-a-long-random-value
HINATA_S3_BUCKET=hinata
The MinIO web console is available on port 9001 and the S3 API on 9000.
In local dev (docker-compose.dev.yml) both are published on loopback —
http://localhost:9001 (console) and http://localhost:9000 (API) — with the dev
keys hinata / hinata-dev-secret.
Change the MinIO password before production
hinata-dev-secret is a development default. Set a long random
MINIO_ROOT_PASSWORD (e.g. from ./deploy/generate-secrets.sh) for any real
deployment, and never publish the MinIO ports to the public internet — only the
server needs to reach them.
The bucket is created for you#
You do not have to pre-create the bucket. On the first upload the server checks
whether HINATA_S3_BUCKET exists and calls makeBucket if it doesn't. The bucket
stays private — nothing is ever made public-read. Every download is brokered by
the server (see below), so objects are never served straight from the bucket.
Tip
If you'd rather create the bucket yourself (for example to set a lifecycle rule
or bucket policy in advance), do so with the default name hinata, or set
HINATA_S3_BUCKET to the name you created. The server will happily reuse an
existing bucket.
Presigned downloads and randomized keys#
Two design choices keep object storage safe by default:
- Randomized object keys. A user-supplied file name never becomes the object
key. The server stores each attachment under a random UUID (optionally behind a
prefix like
media/oravatars/), so a bucket key cannot be guessed and the original file name never touches the bucket layout. - Presigned, short-lived downloads. When a client requests an attachment, the
server returns a presigned GET URL that is valid for 10 minutes and carries
a
Content-Disposition: attachmentheader (so files download rather than render inline). The bucket itself never needs to be public.
This means the S3 credentials stay entirely server-side; clients only ever see time-limited URLs.
Live attachment events (SSE)#
Attachment changes are pushed to everyone viewing an issue in real time over Server-Sent Events:
GET /api/v1/issues/{issueId}/attachments/stream
When someone uploads or removes a file — or drops several at once — every open viewer sees the grid update live, without polling. The stream is in-process per server instance; for a clustered deployment you would front it with a shared broker.
Size and content-type limits#
Uploads are validated on multiple axes. The defaults:
| Setting | Env / property | Default |
|---|---|---|
| Max size of a single file | HINATA_STORAGE_MAX_UPLOAD_MB |
25 MB |
| Max files in one request | hinata.storage.max-files-per-request |
10 |
| Max total size of one request | HINATA_STORAGE_MAX_REQUEST_MB |
100 MB |
Allowed content types are an explicit allow-list — PNG, JPEG, GIF, WebP, PDF, plain text, CSV, ZIP, JSON, and the OOXML Word/Excel documents. A few important safeguards:
image/svg+xmlis intentionally excluded, because SVG can embed JavaScript (a stored-XSS risk).- Magic-byte verification. For binary types the server checks the file's leading bytes against the declared content type, so a file cannot masquerade as, say, a PNG.
- A rejected upload returns a localized, stable error (
error.storage.fileTooLarge,error.storage.fileTypeNotAllowed,error.storage.contentMismatch).
Two size ceilings work together
Spring's multipart limits (max-file-size / max-request-size, driven by the
same MB values) are the outer guard; the app then enforces the file count and
aggregate size on top. Raise all of them together if you need larger uploads.
Using an external provider instead of MinIO#
To use an external store, turn the bundled MinIO off by clearing the compose
profile in .env —
COMPOSE_PROFILES=
— and configure one of the providers below. The MINIO_ROOT_* variables can then
be removed.
AWS S3#
HINATA_S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
HINATA_S3_ACCESS_KEY=AKIA...
HINATA_S3_SECRET_KEY=your-secret-access-key
HINATA_S3_BUCKET=my-hinata-bucket
HINATA_S3_REGION=eu-central-1
Google Cloud Storage#
GCS speaks S3 through its interoperable XML API. Create HMAC keys in the Cloud Console under Cloud Storage → Settings → Interoperability (for a service account, recommended) and point Hinata at the interop endpoint:
HINATA_S3_ENDPOINT=https://storage.googleapis.com
HINATA_S3_ACCESS_KEY=GOOG1E... # HMAC access id
HINATA_S3_SECRET_KEY=your-hmac-secret
HINATA_S3_BUCKET=my-hinata-bucket
HINATA_S3_ADDRESSING_STYLE=path
Azure Blob Storage#
Azure has no S3 API, so Hinata talks to it natively. Switch the provider and pass the storage account's connection string (portal → storage account → Access keys). An account-key connection string is required — presigned downloads are issued as SAS URLs:
HINATA_STORAGE_PROVIDER=azure
HINATA_AZURE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net
HINATA_S3_BUCKET=hinata # used as the blob container name
Other S3-compatible providers#
Cloudflare R2, DigitalOcean Spaces, Backblaze B2, Wasabi, Ceph, Hetzner, a managed
MinIO, … all work with the same HINATA_S3_* variables — endpoint, keys and region
come from the provider's dashboard.
Notes:
HINATA_S3_REGIONdefaults tous-east-1; set it to your bucket's region for AWS and providers that care about it.HINATA_S3_ADDRESSING_STYLE(defaultauto) controls S3 URL addressing:autopicks virtual-host style for AWS endpoints and path style elsewhere, which is right for almost everyone; setpathorvirtual-hostexplicitly if your provider requires it.- Use HTTPS for any endpoint crossing the network.
- The credentials need permission to
PutObject,GetObject,DeleteObject,ListBucket, and (unless you pre-create the bucket)CreateBucket— or the Azure equivalents (the container is created automatically too). - If storage is left unconfigured (blank access key, or blank connection string
with
provider=azure), attachment and avatar endpoints respond witherror.storage.notConfigured— the rest of Hinata still works, you just can't upload files. - Switching providers does not migrate existing objects. Copy the bucket
contents first (
mc mirror,aws s3 sync,azcopy) if the instance already holds data.
Keep buckets private
Whichever provider you use, keep the bucket private. Hinata never needs public-read: it always hands out short-lived presigned URLs, so public access would only widen your attack surface.
See the Configuration reference for the full variable list, and MongoDB & X.509 for the database side of the stack.