Store Django Media on Hetzner Object Storage

Use Hetzner Object Storage for Django media and static files with django-storages: create a bucket and S3 credentials, set the regional endpoint and addressing style, choose public or private access with presigned URLs, configure CORS, and keep costs low next to Hetzner Cloud servers.

Problem statement

Your Django app runs on Hetzner Cloud, and uploads are on the server's disk, which complicates scaling to a second server and backups. Hetzner Object Storage provides S3-compatible buckets in the same locations as your servers, so django-storages can store media there with a few settings. The details that trip people up are the endpoint and region values, addressing style, ACL support, and CORS for direct uploads.

Django on Hetzner storagedjango-storagesS3 backendRegional endpointfsn1, nbg1, hel1Bucketpublic or privateDeliverydirect or presignedAn S3-compatible bucket next to your servers replaces local media folders.
  1. Create a bucket in the same location as your servers (for example fsn1), and generate S3 credentials in the Cloud Console.
  2. Set endpoint_url to the location's endpoint, such as https://fsn1.your-objectstorage.com, and region_name to the location.
  3. Use virtual-hosted addressing and signature_version = "s3v4".
  4. For private media, keep querystring_auth = True (presigned URLs); for public assets, use a public bucket policy.
  5. Configure CORS on the bucket if browsers upload directly.

Step-by-step solution

1. Create the bucket and credentials

In the Hetzner Cloud Console, open Object Storage, create a bucket in the location where your servers run, and create S3 credentials (an access key and secret). Store them as environment variables on the servers. Keeping bucket and servers in the same location avoids latency and cross-location traffic.

2. Configure django-storages

STORAGES = {
    "default": {
        "BACKEND": "storages.backends.s3.S3Storage",
        "OPTIONS": {
            "bucket_name": "shop-media",
            "endpoint_url": "https://fsn1.your-objectstorage.com",
            "region_name": "fsn1",
            "access_key": os.environ["HETZNER_S3_ACCESS_KEY"],
            "secret_key": os.environ["HETZNER_S3_SECRET_KEY"],
            "signature_version": "s3v4",
            "addressing_style": "virtual",
            "default_acl": None,
            "querystring_auth": True,
            "file_overwrite": False,
        },
    },
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

Check Hetzner's documentation for the exact endpoint host names of each location. With these settings, instance.file.url returns a presigned URL valid for an hour by default.

option and valueValueWhyendpoint_urllocation endpointnot AWS defaultregion_namefsn1 / nbg1 / hel1signing scopeaddressing_stylevirtualbucket in hostsignature_versions3v4requiredquerystring_authTrue for privatepresigned URLsEndpoint and region must match the bucket’s locationdjango-deployment.com
A mismatch between region and endpoint shows up as SignatureDoesNotMatch.

3. Public files

For assets that should be publicly readable, such as product images, use a separate bucket or prefix with a bucket policy allowing s3:GetObject, and set querystring_auth = False and a custom_domain if you front it with a CDN. Avoid relying on object ACLs; policies are clearer and portable.

4. Direct browser uploads

For large files, generate presigned POST or PUT URLs in Django and let the browser upload directly. The bucket needs a CORS configuration that allows your site's origin:

{
  "CORSRules": [{
    "AllowedOrigins": ["https://shop.example.com"],
    "AllowedMethods": ["PUT", "POST", "GET"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3000
  }]
}

Apply it with any S3 tool, for example aws s3api put-bucket-cors --endpoint-url https://fsn1.your-objectstorage.com ....

5. Migrate existing files

Copy existing uploads from the server disk to the bucket with rclone or aws s3 sync using the Hetzner endpoint, keeping the same relative paths so database file names stay valid:

aws s3 sync /srv/app/media s3://shop-media --endpoint-url https://fsn1.your-objectstorage.com

Then switch the storage setting and deploy.

migration order1Create bucket and keyssame location2Sync existing filessame relative paths3Switch STORAGES defaultdeploy4Sync again for new uploadsduring cutover5Remove local mediaafter checksKeeping paths identical means no database changesdjango-deployment.com
A second sync catches files uploaded between the first copy and the deploy.

Explanation

Hetzner Object Storage implements the S3 API, so boto3 and django-storages work by pointing at its endpoint. Signature Version 4 signatures include the region, which must match what the service expects for the bucket's location. Moving media off the server makes web servers stateless, which means you can add servers behind a load balancer or rebuild a server without losing uploads.

Edge cases / notes

  • Feature differences. Not every AWS S3 feature exists (for example some lifecycle or event options); check Hetzner's supported feature list before relying on one.
  • Backups. Object storage is durable but not a backup against deletion; enable versioning where available or copy to a second provider.
  • Static files. WhiteNoise on the servers is usually simpler than putting static files in the bucket.

Next steps

FAQ

Does django-storages work with Hetzner Object Storage?

Yes, with the S3 backend and Hetzner's endpoint and region.

Should media be public or private?

Private with presigned URLs for user uploads; public with a bucket policy for assets meant for everyone.

Why do I get SignatureDoesNotMatch?

Usually the region or endpoint does not match the bucket's location, or the signature version is not s3v4.

Can I keep using the same file paths?

Yes. Sync files with the same relative paths and the database values stay valid.

Keep going