Hetzner S3-compatible storage
Submission file uploads (the SubmissionFile model) are stored on a Hetzner Object Storage bucket using the S3-compatible API. EU data residency, no AWS, ~3× cheaper than S3.
What you need
- A Hetzner Cloud account.
- A Project (or willingness to use the default one).
- Card on file (Hetzner doesn't offer a perpetual free tier for object storage; €0.0099/GB/month + €0.99/TB egress is current pricing).
Step 1 - Create an Object Storage bucket
- Sign in to https://console.hetzner.cloud.
- Open or create a Project.
- Left sidebar → Object Storage → Create Bucket.
- Name:
Formspring-uploads-prod(must be globally unique within Hetzner). - Location: pick the same region as your app server (Falkenstein
fsn1, Helsinkihel1, or Nurembergnbg1). - Access:
Private(default - uploads use signed URLs for downloads). - Create Bucket.
Step 2 - Generate S3 credentials
- Object Storage → Credentials tab → Generate Credentials.
- Name:
Formspring-prod. - Hetzner shows:
- Access Key (similar to AWS access-key-id).
- Secret Key (the corresponding secret).
- Endpoint URL - region-specific, e.g.
https://fsn1.your-objectstorage.com.
- Copy all three. Hetzner shows the secret once.
Step 3 - Configure Formspring env
# AWS-compatible env vars (the S3 driver re-uses these)
AWS_ACCESS_KEY_ID=<access key from Hetzner>
AWS_SECRET_ACCESS_KEY=<secret>
AWS_DEFAULT_REGION=fsn1 # or hel1, nbg1
AWS_BUCKET=Formspring-uploads-prod
AWS_ENDPOINT=https://fsn1.your-objectstorage.com
AWS_USE_PATH_STYLE_ENDPOINT=true # critical for non-AWS S3
AWS_URL=null # let signed URLs use the endpoint
These are read by config/filesystems.php → disks.s3 block. Restart after editing.
Step 4 - Verify
-
Confirm the disk by listing all files in the configured
s3disk from the application REPL. Expect either[](empty bucket) or an array of files. An exception means credentials/endpoint are wrong. -
Submit a form with a file upload. The file should appear in the bucket via Hetzner's web UI.
-
Open a submission in Formspring - the file URL the dashboard generates should be a signed URL pointing at
<endpoint>/<bucket>/...?X-Amz-Algorithm=....
Where the credential lives
- Server:
.env→config/filesystems.phpdisks.s3. - Storage usage:
app/Models/SubmissionFile.php(and any other model usingStorage::disk('s3')). - Signed URLs: generated via
$disk->temporaryUrl(...).
Lifecycle policies (recommended)
For cost control on large uploads, set a lifecycle rule to delete old objects:
-
Hetzner doesn't have a UI for lifecycle rules yet (as of Q1 2026); use AWS CLI configured against the Hetzner endpoint:
bash aws s3api put-bucket-lifecycle-configuration \ --bucket Formspring-uploads-prod \ --endpoint-url https://fsn1.your-objectstorage.com \ --lifecycle-configuration file://lifecycle.json -
lifecycle.json:json { "Rules": [{ "Id": "expire-temp-uploads", "Status": "Enabled", "Filter": { "Prefix": "tmp/" }, "Expiration": { "Days": 7 } }] }
CORS (only if you allow direct browser uploads)
Currently Formspring uploads go through the server, not direct browser-to-S3. If you ever switch to presigned-PUT direct uploads, set CORS:
aws s3api put-bucket-cors \
--bucket Formspring-uploads-prod \
--endpoint-url https://fsn1.your-objectstorage.com \
--cors-configuration file://cors.json
cors.json:
{
"CORSRules": [{
"AllowedOrigins": ["https://formspring.io"],
"AllowedMethods": ["PUT", "POST"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3000
}]
}
Security
- Rotate by generating a new credential pair in Hetzner → updating env → restarting → revoking the old credential.
- Buckets default to private - uploads aren't public-readable. The platform serves them via short-lived signed URLs (default 5-minute expiry; configurable).
- Hetzner uses TLS for all S3 traffic; the AWS SDK does too. No plaintext.
- Encryption at rest: Hetzner encrypts every object with AES-256 server-side by default.
Migration from AWS S3
If you were previously on AWS S3, the migration is essentially:
aws s3 sync s3://old-bucket s3://new-bucket --endpoint-url https://fsn1.your-objectstorage.com(with both sets of credentials).- Update env to point at Hetzner.
- Restart.
There's no breaking schema change - the SubmissionFile.path column is the S3 key, which stays the same.
Troubleshooting
| Symptom | Cause |
|---|---|
SignatureDoesNotMatch |
Wrong AWS_DEFAULT_REGION. Hetzner regions are fsn1, hel1, nbg1 - not eu-central-1. |
NoSuchBucket |
Bucket name typo or wrong region (each Hetzner region has its own bucket namespace). |
| Signed URLs return 403 in browser | URL expired (default 5 min). Tune the expiry in your temporaryUrl() call. |
| Uploads succeed but file is 0 bytes | Streaming upload failed mid-flight. Check the user's network or chunk size. |
Provider docs
- Hetzner Object Storage: https://docs.hetzner.com/cloud/object-storage/
- S3-compatible API: https://docs.hetzner.com/cloud/object-storage/general/
- Pricing: https://www.hetzner.com/cloud/#pricing
- Region endpoints: https://docs.hetzner.com/cloud/object-storage/connecting-to-object-storage/