All docs
4 min read

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

  1. Sign in to https://console.hetzner.cloud.
  2. Open or create a Project.
  3. Left sidebar → Object StorageCreate Bucket.
  4. Name: Formspring-uploads-prod (must be globally unique within Hetzner).
  5. Location: pick the same region as your app server (Falkenstein fsn1, Helsinki hel1, or Nuremberg nbg1).
  6. Access: Private (default - uploads use signed URLs for downloads).
  7. Create Bucket.

Step 2 - Generate S3 credentials

  1. Object StorageCredentials tab → Generate Credentials.
  2. Name: Formspring-prod.
  3. 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.
  4. Copy all three. Hetzner shows the secret once.

Step 3 - Configure Formspring env

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.phpdisks.s3 block. Restart after editing.

Step 4 - Verify

  1. Confirm the disk by listing all files in the configured s3 disk from the application REPL. Expect either [] (empty bucket) or an array of files. An exception means credentials/endpoint are wrong.

  2. Submit a form with a file upload. The file should appear in the bucket via Hetzner's web UI.

  3. 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: .envconfig/filesystems.php disks.s3.
  • Storage usage: app/Models/SubmissionFile.php (and any other model using Storage::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:

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

bash
aws s3api put-bucket-cors \
  --bucket Formspring-uploads-prod \
  --endpoint-url https://fsn1.your-objectstorage.com \
  --cors-configuration file://cors.json

cors.json:

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:

  1. aws s3 sync s3://old-bucket s3://new-bucket --endpoint-url https://fsn1.your-objectstorage.com (with both sets of credentials).
  2. Update env to point at Hetzner.
  3. 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