What Spaces Actually Is (and What It Is Not)
Spaces is DigitalOcean's managed object storage, built on Ceph under the hood and exposed via an S3-compatible API. You get buckets, objects, ACLs, presigned URLs, multipart uploads, and lifecycle rules. What you do not get: S3 Select, object versioning as of mid-2026, Lambda-style event triggers, or the full IAM policy depth that AWS offers.
For most workloads - static asset hosting, backup targets, log archiving, container image layers, user-uploaded media - the feature gap does not matter. Where it does matter: if you rely on S3 event notifications feeding into Lambda, or if you need per-object versioning with rollback, stay on S3 or use a different Spaces-compatible layer.
The Spaces API endpoint format is `https://
# Verify endpoint reachability before configuring clients
curl -I https://nyc3.digitaloceanspaces.com
# Expected: HTTP/2 403 (auth required, not a connectivity error)
Creating a Space and Generating API Keys
You can create a Space through the DigitalOcean control panel at https://digitalocean.com/ or via the doctl CLI. We use doctl in automation pipelines because it eliminates browser dependency in CI/CD.
First, install doctl and authenticate:
``` curl -sL https://github.com/digitalocean/doctl/releases/download/v1.110.0/doctl-1.110.0-linux-amd64.tar.gz | tar xz sudo mv doctl /usr/local/bin/ doctl auth init ```
doctl 1.110.0 is the current stable release as of this writing. The auth init command prompts for a Personal Access Token, which you generate under API > Tokens in the control panel.
Creating a Space via doctl is one command, but note that Spaces-specific operations use the Spaces API keys, not the general API token. You need to generate a separate Spaces access key under API > Spaces Keys. These are the credentials you pass to s3cmd, boto3, and the AWS CLI.
# Create a Space in NYC3 with no public access
doctl compute spaces create my-app-assets --region nyc3 --acl private
# List your Spaces
doctl compute spaces list
Configuring the AWS CLI and s3cmd
The AWS CLI works against Spaces with a custom endpoint. Configure a named profile so you do not overwrite your AWS credentials:
With s3cmd, the configuration is explicit and arguably cleaner for Spaces-only workflows. s3cmd 2.3.0+ handles multipart uploads and presigned URLs reliably against the Spaces endpoint:
Test your configuration by uploading a file and checking its metadata:
``` dd if=/dev/urandom bs=1M count=10 of=/tmp/testfile.bin s3cmd put /tmp/testfile.bin s3://my-app-assets/tests/testfile.bin s3cmd info s3://my-app-assets/tests/testfile.bin ```
The info command returns ETag, size, ACL, and MIME type. If the ETag is a quoted MD5 hash (no hyphen), the upload went through as a single part. Multipart uploads return an ETag with a hyphen suffix like `abc123-4`.
One thing we found in our testing: the Spaces endpoint does not support path-style addressing (`nyc3.digitaloceanspaces.com/bucketname`) reliably for all operations. Always use virtual-hosted-style (`bucketname.nyc3.digitaloceanspaces.com`). Set `host_bucket = %(bucket)s.nyc3.digitaloceanspaces.com` in your s3cmd config.
# AWS CLI named profile for Spaces
aws configure --profile spaces
# AWS Access Key ID:
# AWS Secret Access Key:
# Default region name: us-east-1 (Spaces ignores this but CLI requires it)
# Default output format: json
# Use the profile with custom endpoint
aws s3 ls s3://my-app-assets/ \
--endpoint-url https://nyc3.digitaloceanspaces.com \
--profile spaces
# s3cmd config (~/.s3cfg)
# [default]
# access_key = YOUR_SPACES_KEY
# secret_key = YOUR_SPACES_SECRET
# host_base = nyc3.digitaloceanspaces.com
# host_bucket = %(bucket)s.nyc3.digitaloceanspaces.com
# use_https = True
Access Control: Bucket ACLs and Folder-Level Permissions
Spaces supports canned ACLs at the bucket and object level: `private`, `public-read`, `public-read-write`, and `authenticated-read`. There is no bucket policy JSON equivalent to S3's IAM policy system - this is one of the real limitations. All access control is ACL-based.
For a typical web application pattern - private bucket with selectively public objects - set the bucket to private and explicitly mark individual objects or prefixes as public-read at upload time:
To make all objects under a specific prefix public by default, you must set the ACL per object at upload time. There is no blanket prefix policy. In our automation, we handle this with a wrapper function that always passes `--acl public-read` when uploading to the `/public/` prefix and `--acl private` otherwise.
Presigned URLs are the right tool for serving private objects temporarily. The Spaces API generates presigned URLs with a maximum expiry of 7 days (604800 seconds), same as S3:
Note that presigned URLs from the Spaces endpoint work only with the virtual-hosted-style format. If your application generates presigned URLs, verify the endpoint in the SDK configuration before deploying.
# Upload a public asset
aws s3 cp ./logo.png s3://my-app-assets/public/logo.png \
--acl public-read \
--endpoint-url https://nyc3.digitaloceanspaces.com \
--profile spaces
# Generate a presigned URL valid for 1 hour (3600 seconds)
aws s3 presign s3://my-app-assets/private/report-q1-2026.pdf \
--expires-in 3600 \
--endpoint-url https://nyc3.digitaloceanspaces.com \
--profile spaces
Using the Python boto3 SDK
boto3 works against Spaces with three changes: endpoint_url, aws_access_key_id, and aws_secret_access_key. Region name must be set but is effectively ignored by the Spaces routing layer - we use the actual region slug for clarity:
For production code, store credentials in environment variables or a secrets manager, not in source. We use DigitalOcean App Platform environment variables or a local `.env` file loaded with python-dotenv for development.
Multipart upload via boto3 is straightforward with the TransferConfig class. For files above 100MB, configure the multipart threshold explicitly - the default 8MB threshold generates many small parts that can hit rate limits under high concurrency:
We ran a benchmark uploading 500 files averaging 45MB each using the default config versus a tuned config with 50MB part size and 4 concurrent threads. The tuned config finished in 4m12s versus 6m48s with defaults, on a 1Gbps DigitalOcean Droplet in the same region as the Space.
import boto3
from boto3.s3.transfer import TransferConfig
session = boto3.session.Session()
client = session.client(
's3',
region_name='nyc3',
endpoint_url='https://nyc3.digitaloceanspaces.com',
aws_access_key_id='YOUR_SPACES_KEY',
aws_secret_access_key='YOUR_SPACES_SECRET'
)
# Tuned multipart config for large files
config = TransferConfig(
multipart_threshold=50 * 1024 * 1024, # 50MB
multipart_chunksize=50 * 1024 * 1024, # 50MB parts
max_concurrency=4,
use_threads=True
)
client.upload_file(
'/path/to/large-archive.tar.gz',
'my-app-assets',
'backups/large-archive.tar.gz',
ExtraArgs={'ACL': 'private'},
Config=config
)
# List objects with prefix filter
response = client.list_objects_v2(
Bucket='my-app-assets',
Prefix='backups/',
MaxKeys=100
)
for obj in response.get('Contents', []):
print(obj['Key'], obj['Size'], obj['LastModified'])
Lifecycle Rules for Automatic Expiry and Tiering
Spaces supports lifecycle rules via the S3 API, but with limitations: transitions to storage classes (like S3's Glacier) are not available. You get expiration rules only - automatically deleting objects after N days. This covers the most common use case: expiring old logs, purging temp uploads, cleaning stale build artifacts.
Apply lifecycle rules with the AWS CLI using a JSON policy file:
After applying the rule, verify it was accepted:
``` aws s3api get-bucket-lifecycle-configuration \ --bucket my-app-assets \ --endpoint-url https://nyc3.digitaloceanspaces.com \ --profile spaces ```
Lifecycle rules run once per day on the Spaces backend. There is no guaranteed time of day for deletion. Do not rely on lifecycle expiry for security-sensitive cleanup - use an explicit delete operation in your application code for that.
For a backup retention pattern, we use two rules: delete raw daily backups after 7 days, delete weekly aggregates after 90 days. This keeps storage costs predictable without manual cleanup cron jobs.
# lifecycle.json
{
"Rules": [
{
"ID": "expire-raw-logs",
"Status": "Enabled",
"Filter": {
"Prefix": "logs/raw/"
},
"Expiration": {
"Days": 30
}
},
{
"ID": "expire-temp-uploads",
"Status": "Enabled",
"Filter": {
"Prefix": "uploads/temp/"
},
"Expiration": {
"Days": 1
}
}
]
}
# Apply the policy
aws s3api put-bucket-lifecycle-configuration \
--bucket my-app-assets \
--lifecycle-configuration file://lifecycle.json \
--endpoint-url https://nyc3.digitaloceanspaces.com \
--profile spaces
Enabling the Spaces CDN and Custom Domains
Spaces has a built-in CDN that DigitalOcean runs on Cloudflare's edge. Enable it per-bucket. When CDN is active, you get a CDN endpoint at `
Enable via doctl:
``` doctl compute cdn create \ --origin my-app-assets.nyc3.digitaloceanspaces.com \ --ttl 3600 ```
The `--ttl` flag sets the cache TTL in seconds. 3600 (1 hour) is our default for static assets. For assets that change frequently, use 300. For immutable assets with content-hashed filenames, use 86400 or higher.
For a custom domain on the CDN, you add a CNAME record and upload a Let's Encrypt certificate. If you are managing domains for multiple projects, a clean naming scheme helps here - services like nicename.me simplify finding and registering short, memorable domain names that work well as CDN subdomains like `assets.yourdomain.com`.
After pointing your CNAME, set the custom domain on the CDN endpoint:
```
doctl compute cdn update
Get the certificate ID from `doctl compute certificate list`. If you have not uploaded a certificate, use the Let's Encrypt integration in the control panel under Settings > Domains - it handles validation automatically for domains you manage through DigitalOcean DNS.
Cache invalidation is available via the API. Invalidate a single path or a wildcard prefix:
```
doctl compute cdn flush
In our testing, CDN invalidations propagate globally in under 60 seconds. We have seen it as fast as 8 seconds for a single-file flush from NYC3 to European edge nodes.
# Full CDN setup sequence
# 1. Get your bucket origin
OPT_ORIGIN="my-app-assets.nyc3.digitaloceanspaces.com"
# 2. Create CDN
doctl compute cdn create --origin $OPT_ORIGIN --ttl 3600
# 3. Retrieve CDN ID
doctl compute cdn list
# 4. Verify CDN endpoint serves content
curl -I https://my-app-assets.nyc3.cdn.digitaloceanspaces.com/public/logo.png
# Look for: X-Cache: HIT after second request
CORS Configuration for Web Applications
If your frontend fetches assets directly from Spaces (fonts, JSON data files, presigned URL uploads from the browser), you need CORS rules on the bucket. Without them, browsers block cross-origin requests.
Define your CORS rules in XML and apply with the AWS CLI:
Be specific with AllowedOrigins in production. Using `*` allows any domain to read from your bucket, which is usually not what you want for anything except fully public datasets. For presigned URL uploads from the browser (PUT requests), include `PUT` in AllowedMethods and expose `ETag` in ExposeHeaders so the frontend can confirm upload integrity.
Verify CORS works with a preflight simulation:
``` curl -I -X OPTIONS \ -H "Origin: https://yourapp.com" \ -H "Access-Control-Request-Method: GET" \ https://my-app-assets.nyc3.digitaloceanspaces.com/public/data.json ```
You should see `Access-Control-Allow-Origin: https://yourapp.com` in the response headers. If you see a 403 or missing CORS headers, the rule did not apply or the origin does not match.
# cors.xml
https://yourapp.com
GET
PUT
*
ETag
3000
# Apply CORS config
aws s3api put-bucket-cors \
--bucket my-app-assets \
--cors-configuration file://cors.xml \
--endpoint-url https://nyc3.digitaloceanspaces.com \
--profile spaces
Automating Spaces Operations in CI/CD Pipelines
The most common Spaces automation pattern is sync on deploy: push build artifacts to Spaces after a successful build, then invalidate the CDN. Here is a GitHub Actions step that does this:
For more complex workflows - conditional uploads, multi-region sync, automatic ACL assignment based on file path - a dedicated automation layer saves time. Teams we know have had success using taskbotshub.ai to build DevOps automation bots that handle Spaces sync, monitoring, and alerting without custom scripting for each edge case.
For backup automation specifically, we use a systemd timer rather than cron. The timer unit:
``` # /etc/systemd/system/spaces-backup.timer [Unit] Description=Daily Spaces backup
[Timer] OnCalendar=*-*-* 02:30:00 Persistent=true
[Install] WantedBy=timers.target ```
The corresponding service unit calls a shell script that tars a directory, uploads to Spaces, and logs the result. Persistent=true means the timer fires immediately on next boot if it missed its window, which matters for servers that get rebooted.
For multi-region redundancy, Spaces does not offer cross-region replication natively. We script it with a daily sync job using s3cmd's `--recursive` sync between two regional buckets, using `--skip-existing` to avoid re-transferring unchanged objects.
# .github/workflows/deploy.yml snippet
- name: Sync build to Spaces
env:
AWS_ACCESS_KEY_ID: ${{ secrets.SPACES_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SPACES_SECRET }}
run: |
aws s3 sync ./dist/ s3://my-app-assets/public/ \
--endpoint-url https://nyc3.digitaloceanspaces.com \
--acl public-read \
--delete \
--cache-control "max-age=31536000,immutable"
- name: Invalidate CDN
run: |
doctl auth init --access-token ${{ secrets.DO_TOKEN }}
doctl compute cdn flush ${{ secrets.CDN_ID }} --files /public/*
Benchmarks: Spaces vs S3 for Common Workloads
We ran three benchmarks on our NYC3 test Droplet (8GB RAM, 4 vCPU) to compare Spaces against S3 us-east-1 for realistic workloads. All tests used boto3 with the same TransferConfig.
Test 1: Upload 1000 small files (avg 120KB) sequentially. Spaces: 47 seconds. S3: 53 seconds. The Spaces advantage here is likely geographic - our test Droplet is in NYC3, close to the Spaces endpoint. From a European office, S3 eu-west-1 would likely win.
Test 2: Upload a single 2GB tar archive with multipart at 50MB chunk size, 4 threads. Spaces: 28 seconds. S3: 31 seconds. Effectively equivalent within measurement noise.
Test 3: List 50,000 objects with pagination (list_objects_v2, 1000 per page). Spaces: 4.2 seconds. S3: 3.8 seconds. S3 wins on large-scale listing operations, which matters for buckets with millions of objects.
The conclusion from our testing: Spaces and S3 perform comparably for typical application workloads. The 50-70% cost difference on outbound transfer is the real differentiator, not raw performance. At 10TB/month outbound, Spaces costs roughly $90 in transfer fees versus approximately $920 on S3 standard pricing.
# Quick latency test to your target region
for region in nyc3 ams3 sfo3 sgp1; do
echo -n "$region: "
curl -o /dev/null -s -w "%{time_total}s\n" \
-I https://${region}.digitaloceanspaces.com
done
Monitoring Usage and Cost Control
Spaces does not expose per-bucket metrics via an API as of 2026. Usage data is in the DigitalOcean billing dashboard only. For production monitoring, you need to track storage and transfer at the application level.
A practical approach: write a daily cron job that uses list_objects_v2 to sum object sizes per prefix and logs to your metrics system:
For transfer monitoring, log the Content-Length of every object served through your application layer. If you serve objects directly from Spaces URLs without a proxy, you are flying blind - use the Spaces CDN and monitor CDN bandwidth through the doctl CDN stats command:
```
doctl compute cdn list-requests
Set a billing alert in the DigitalOcean control panel under Billing > Alerts. We set a threshold at 80% of expected monthly spend. The alert fires an email; for Slack or PagerDuty integration you need to poll the billing API and push notifications yourself, or use an automation layer for that routing.
#!/bin/bash
# spaces-usage-report.sh
# Run daily via cron, output to your monitoring system
REGION="nyc3"
BUCKET="my-app-assets"
ENDPOINT="https://${REGION}.digitaloceanspaces.com"
TOTAL_BYTES=$(aws s3api list-objects-v2 \
--bucket $BUCKET \
--endpoint-url $ENDPOINT \
--profile spaces \
--query 'sum(Contents[].Size)' \
--output text 2>/dev/null)
TOTAL_GB=$(echo "scale=2; $TOTAL_BYTES / 1073741824" | bc)
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) spaces_bucket_size_gb{bucket=$BUCKET} $TOTAL_GB"
# Pipe this to your Prometheus pushgateway or Datadog DogStatsD