Cloud object storage: comparing S3 providers
Object storage is the optimal way to store static assets, media files, backups, and ML datasets. All providers use an S3-compatible API, which makes switching providers easier.
Comparing S3 providers
| Provider | Price (GB/mo) | Outbound traffic | Region |
|---|---|---|---|
| Hetzner Object Storage | €0.0049 | €0.010/GB (first 1TB free) | Germany, Finland |
| Backblaze B2 | $0.006 | $0.01/GB | US, EU |
| Cloudflare R2 | $0.015 | FREE | Global |
| DigitalOcean Spaces | $0.020 | $0.01/GB (first 1TB free) | US, EU, Asia |
| AWS S3 | $0.023 | $0.09/GB | Global |
Cloudflare R2: free outbound traffic
# R2 via rclone
[cloudflare-r2]
type = s3
provider = Cloudflare
access_key_id = YOUR_R2_ACCESS_KEY
secret_access_key = YOUR_R2_SECRET_KEY
endpoint = https://ACCOUNT_ID.r2.cloudflarestorage.com
region = autoConnecting to WordPress (WP Offload Media)
# wp-config.php
define('AS3CF_SETTINGS', serialize([
'provider' => 's3',
'access-key-id' => 'YOUR_KEY',
'secret-access-key' => 'YOUR_SECRET',
'bucket' => 'my-wp-media',
'region' => 'eu-central-1',
'domain' => 'path', // or a custom domain via CDN
]));PHP: uploading files to S3
use Aws\S3\S3Client;
$s3 = new S3Client([
'version' => 'latest',
'region' => 'eu-central-1',
'endpoint' => 'https://YOUR_ENDPOINT',
'credentials' => ['key' => 'KEY', 'secret' => 'SECRET'],
'use_path_style_endpoint' => true,
]);
// Upload file
$s3->putObject([
'Bucket' => 'my-bucket',
'Key' => 'uploads/' . basename($_FILES['file']['name']),
'SourceFile' => $_FILES['file']['tmp_name'],
'ACL' => 'public-read',
'ContentType' => $_FILES['file']['type'],
]);
// Get the public URL
$url = $s3->getObjectUrl('my-bucket', 'uploads/photo.jpg');Real S3 API Errors Explained
Two error messages show up most often for newcomers to object storage, and neither is caused by the network or a quota.
SignatureDoesNotMatch
<Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message></Error>The classic causes: a wrong secret key (an extra space from copy-pasting), the server's system clock drifting by more than 15 minutes, or not setting use_path_style_endpoint => true for providers that do not support virtual-hosted style (MinIO, Hetzner). Check the time with timedatectl and sync it with chronyd or ntpd.
AccessControlListNotSupported
<Error><Code>AccessControlListNotSupported</Code><Message>The bucket does not allow ACLs</Message></Error>Since April 2023, new AWS S3 buckets are created with Object Ownership set to Bucket owner enforced, which disables ACLs entirely. Code with 'ACL' => 'public-read' in putObject starts failing with this error. The fix is to drop the ACL parameter and grant access through a JSON bucket policy instead of an object-level ACL.
When Object Storage Is Not a Good Fit
- You need POSIX semantics with file locking and atomic append — S3 is not a filesystem, it is a store for whole, immutable objects.
- The application needs low latency for block-level database access — use local NVMe or network block storage, not an object API.
- You are storing a huge number of very small files with a high request rate — billing for operations (PUT/GET) starts to exceed billing for volume, so it makes more sense to bundle files or put a CDN cache in front of the bucket.
- You need real-time writes with minimal latency (streaming video, telemetry) — object storage was designed for batch writes, not a stream.
- Regulatory requirements mandate physical control over the media — then deploy your own MinIO on your own hardware instead of a cloud provider.
Verifying the Result
# Upload a test file and check the checksum
aws s3 cp test.txt s3://my-bucket/test.txt --endpoint-url https://YOUR_ENDPOINT
aws s3api head-object --bucket my-bucket --key test.txt --endpoint-url https://YOUR_ENDPOINT
# Check public access if the bucket is supposed to be open
curl -I https://my-bucket.YOUR_ENDPOINT/test.txt
# Compare the local and remote MD5
md5sum test.txt
aws s3api head-object --bucket my-bucket --key test.txt --query ETag --endpoint-url https://YOUR_ENDPOINTA 403 response code on a public curl -I almost always means the bucket policy does not allow anonymous reads, not a problem with access keys — keys are not needed at all to read a public object. If the object storage is used for backups, it makes more sense to run them through rclone than to write one-off PHP code for the task.