DigitalOcean Spaces is an S3-compatible object store with a flat monthly subscription, a built-in CDN and two storage tiers. Its appeal is predictability: one price covers a fixed amount of storage and outbound transfer across all your buckets, and the API is close enough to S3 that boto3, the AWS CLI, s3cmd and rclone work with an endpoint change. Its limits are also where the design work is, because they are tighter than the large hyperscaler stores: a request-rate ceiling per bucket, a smaller set of supported S3 features and a lifecycle model that does less.
This article explains what Spaces is underneath the S3 API, how its billing works with worked numbers, how to write a client that respects its limits, and where it fits and does not. For the general model of object storage, Cloud Object Storage Architecture in Depth covers the concepts; this page is about Spaces specifically. Product facts were checked against DigitalOcean's documentation on 2026-10-04; limits and prices change, so confirm them before you commit.
What a Space is
A Space is a bucket in one region. You reach it at the regional endpoint https://<region>.digitaloceanspaces.com, or virtual-host style at https://<bucket>.<region>.digitaloceanspaces.com. The region slug, such as nyc3 or fra1, is both the endpoint prefix and the value your SDK passes as its region. Data does not leave that region unless you copy it, so pick the region next to the compute that reads it; transfer between Spaces and Droplets in the same region does not count against your allowance, per the pricing page, which lists the region pairs.
Objects are immutable blobs addressed by key, with metadata and an access control list. There is no directory tree: a slash in a key is a convention the console renders as folders, and listing by prefix is how you walk it. Each object can be private or public-read through its ACL, which is the simplest way to serve public assets.
Three access paths exist. The S3 API with access keys is for applications. The CDN endpoint, <bucket>.<region>.cdn.digitaloceanspaces.com or a custom subdomain with a TLS certificate, serves public objects from edge caches with a default edge TTL of one hour. Presigned URLs give a browser time-limited permission to GET or PUT a single object without holding a key.
Pricing, with a worked bill
Billing is a subscription, not a per-bucket fee. The base subscription is $5.00 a month and includes 250 GiB of Standard storage and 1,024 GiB of outbound transfer, shared across all your buckets. Beyond that, Standard storage costs $0.02 per GiB-month and outbound transfer $0.01 per GiB. Inbound transfer never counts. The CDN has no separate fee, but bytes it serves count against the same transfer allowance.
Cold Storage is a second tier at $0.007 per GiB-month, aimed at data you rarely read. It carries conditions that change the design: retrieval is charged per GiB (with an allowance tied to average daily usage), objects are held for a 30-day minimum, deletions or overwrites before then are charged beyond a monthly free amount, objects under 128 KiB are billed as 128 KiB, and Cold Storage buckets cannot use the CDN. Standard storage has its own small-object floor; the limits page lists a 4 KiB minimum billable size.
A worked month: an application stores 1,200 GiB in Standard, serves 3,000 GiB to the internet and archives 5 TiB in Cold Storage. The script below models the published rates; it treats Cold capacity as billed separately from the included 250 GiB, which you should confirm for your account.
BASE, INC_STORE, INC_XFER = 5.00, 250, 1024 # $/month, GiB, GiB
STORE_RATE, XFER_RATE, COLD_RATE = 0.02, 0.01, 0.007 # $/GiB
def billable_gib(objects, avg_kib, floor_kib=4):
return objects * max(avg_kib, floor_kib) / 1024 / 1024
def monthly(std_gib, egress_gib, cold_gib=0.0):
store = max(0.0, std_gib - INC_STORE) * STORE_RATE
xfer = max(0.0, egress_gib - INC_XFER) * XFER_RATE
cold = cold_gib * COLD_RATE
return BASE, store, xfer, cold, BASE + store + xfer + cold
b, s, x, c, tot = monthly(std_gib=1200, egress_gib=3000, cold_gib=5120)
print(f"base {b:.2f} store {s:.2f} xfer {x:.2f} cold {c:.2f} total {tot:.2f}")
thumbs = 20_000_000
print(f"thumbs actual {thumbs * 1.5 / 1024 / 1024:.1f} GiB "
f"billed {billable_gib(thumbs, 1.5):.1f} GiB")
print(f"20M requests at 800 ops/s: {thumbs / 800 / 3600:.1f} h")base 5.00 store 19.00 xfer 19.76 cold 35.84 total 79.60
thumbs actual 28.6 GiB billed 76.3 GiB
20M requests at 800 ops/s: 6.9 hTwo lessons fall out. Egress, not storage, is usually the variable to watch: 3,000 GiB of downloads costs about as much as nearly a terabyte of extra storage, and a popular file served through the CDN spends the same allowance. Second, many tiny objects are billed above their size: 20 million 1.5 KiB thumbnails occupy 28.6 GiB but bill as 76.3 GiB. They also take hours just to write at the bucket's request ceiling. Pack small items into larger objects, or keep them in a database, when the count is large.
A client that respects the limits
Because Spaces speaks the S3 protocol, the client is an S3 client with three settings that matter: the endpoint, a scoped key and a retry policy suited to the rate limit.
import boto3
from boto3.s3.transfer import TransferConfig
from botocore.config import Config
REGION = "fra1"
s3 = boto3.client(
"s3",
region_name=REGION,
endpoint_url=f"https://{REGION}.digitaloceanspaces.com",
aws_access_key_id=KEY_ID, # a key scoped to this one bucket
aws_secret_access_key=KEY_SECRET,
config=Config(retries={"max_attempts": 10, "mode": "adaptive"}),
)
# Large files: multipart with a part size that keeps the count under 10,000
big = TransferConfig(multipart_threshold=64 * 1024**2,
multipart_chunksize=64 * 1024**2, max_concurrency=8)
s3.upload_file("model.safetensors", "acme-assets",
"models/v7/model.safetensors", Config=big,
ExtraArgs={"ACL": "private",
"ContentType": "application/octet-stream"})
# A browser upload: presigned PUT valid for 10 minutes
url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "acme-uploads", "Key": "u/123/avatar.png",
"ContentType": "image/png"},
ExpiresIn=600,
)The rate limit is the constraint to design around. New buckets support 800 total operations per second, and requests beyond the limit receive 503 Slow Down; the documented remedy is retrying with exponential backoff, which botocore's adaptive retry mode does and also slows the client when throttled. Older buckets created before the limits were raised have lower, per-operation-type ceilings, so check which kind you have. If one workload needs more than 800 operations per second, spread keys across several buckets and route by a hash of the key, and prefer fewer, larger objects.
Size limits follow S3 conventions. A single PUT can carry up to 5 GB. Multipart parts must be between 5 MiB and 5 GB, except the last, with at most 10,000 parts and 5 TB per object. The part count sets a minimum part size: a 200 GiB file needs parts of at least about 20.5 MiB, so a fixed 8 MiB chunk size fails on large files. Incomplete multipart uploads are removed automatically after 30 days, but their parts are billed until then, so add a lifecycle rule that aborts them sooner.
Keys, policies and the CDN
An account can have up to 100 buckets and 200 access keys. Keys come in two kinds. A full-access key can call every supported S3 operation on every bucket in the account. A per-bucket key is limited to named buckets with read, or read, write and delete, permission on each. Use per-bucket keys for every application: a leaked full-access key exposes everything, while a scoped read key leaks one bucket's contents. Keys can be managed in the control panel, and the DigitalOcean API and Terraform provider expose a Spaces key resource, so they can live in infrastructure code. A key cannot be converted between full and scoped after creation.
One incompatibility matters: per-bucket keys do not work with S3-style bucket policies, and you cannot create a scoped key on a bucket that uses PutBucketPolicy, or the reverse. Choose one model per bucket. For most applications, scoped keys plus per-object ACLs are simpler than policies.
Public content should go through the CDN rather than the origin, both for latency and to absorb request spikes that would otherwise hit the 800 operations per second ceiling. When you replace a file under the same key, the edge keeps the old version until its TTL expires; either purge the path through the control panel or the CDN API, or, better, publish new versions under new keys such as app.3f9a2c.js and set a long TTL.
Versioning, lifecycle and missing features
Spaces supports a subset of S3 data management, and the gaps decide some architectures.
- Versioning keeps old object versions after overwrite or delete. The documentation says it can only be enabled through the API, with
aws s3api put-bucket-versioning --endpoint-url https://<region>.digitaloceanspaces.com --versioning-configuration Status=Enabled, not in the control panel. Versioned buckets have a lower object-count ceiling than unversioned ones, and every retained version is billed. - Lifecycle rules are documented for two actions: expiring objects after a number of days, and removing incomplete multipart uploads. Noncurrent-version expiration and storage-class transitions are not documented, so do not assume versions age out or that objects move to Cold Storage automatically; copy them to a Cold Storage bucket yourself.
- Not supported: object and bucket tagging, so tag-based lifecycle and cost tracking need key prefixes instead. Cold Storage buckets additionally lack CORS, static website hosting and the CDN.
{
"Rules": [
{"ID": "tmp-expire", "Status": "Enabled",
"Filter": {"Prefix": "tmp/"},
"Expiration": {"Days": 7}},
{"ID": "abort-mpu", "Status": "Enabled",
"Filter": {"Prefix": ""},
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 2}}
]
}Apply it with aws s3api put-bucket-lifecycle-configuration --bucket acme-assets --endpoint-url https://fra1.digitaloceanspaces.com --lifecycle-configuration file://lifecycle.json, then read it back with the matching get call to confirm the service accepted every element.
Worked example: a small SaaS
Consider a small SaaS that serves user avatars, product images and nightly database dumps from Droplets in one region; DigitalOcean Droplets covers that side. A design that fits Spaces looks like this.
- One Standard bucket,
acme-public, with the CDN on a custom subdomain, holding content-hashed image keys with public-read ACLs and a long TTL. - One private bucket,
acme-uploads, receiving browser uploads through presigned PUTs; a worker on the Droplet validates each file, resizes it and writes the result to the public bucket under a new hashed key. - One Cold Storage bucket,
acme-archive, receiving nightly database dumps of several GB each; well above the 128 KiB floor and kept longer than 30 days, so the minimums never bite. - Three per-bucket keys: the web tier gets write on uploads only, the worker gets read on uploads and write on public, the backup job gets write on archive. No full-access key on any server.
- Lifecycle rules: abort incomplete uploads after two days everywhere, expire raw uploads after seven days.
The traffic check: if images average 200 KiB and the CDN serves five million views a month, that is roughly 950 GiB of egress, inside the 1,024 GiB allowance; double the traffic and the overage is about $9. The rate check: at peak, origin requests are CDN misses plus uploads, far below 800 per second. If the product grows into millions of tiny objects or thousands of writes per second, that is the signal to shard across buckets or move to a store with higher ceilings, compared in Cloudflare R2, in depth.
Failure modes
- 503 storms during batch jobs: a migration script with 64 threads exceeds 800 ops/s. Use adaptive retries, cap concurrency and shard hot workloads across buckets.
- Stale CDN content after overwriting a key. Use versioned keys; purge only for emergencies.
- Surprise egress from serving large downloads or from another cloud pulling data across regions. Watch transfer in the billing dashboard monthly.
- Cold Storage surcharges from small objects or short-lived data. Archive only large, long-lived objects; aggregate small ones into tar files first.
- Policy and key conflicts: adding a bucket policy blocks scoped keys on that bucket. Decide the access model before production.
- Assumed S3 features such as tagging or lifecycle transitions that silently do not exist. Test every S3 call you rely on against Spaces, not AWS.
Trade-offs
Spaces wins on simplicity and predictable cost for small and medium workloads, especially next to DigitalOcean compute: one subscription, an included CDN and a familiar API. It loses where you need very high request rates per bucket, rich lifecycle automation, tagging, or object lock style retention for compliance, which is not described in the documentation reviewed here. Compared with hyperscaler stores, you trade breadth of features for a smaller surface you can understand completely; OCI Object Storage is another example of an S3-compatible store with different tiering choices.
What to do next
- Pick the region next to your compute, and confirm current limits and prices on DigitalOcean's pricing and limits pages.
- Create one bucket per access pattern, and one per-bucket key per application role; delete any full-access key from servers.
- Configure your S3 client with the regional endpoint and adaptive retries, and load-test it to see where 503s begin.
- Set a multipart part size from your largest object divided by 10,000, rounded up, never below 5 MiB.
- Add the abort-multipart lifecycle rule to every bucket, and expiration rules for temporary prefixes.
- Put public content behind the CDN with content-hashed keys and a long TTL.
- Model your monthly bill with the script above using real storage, egress and object counts, and archive to Cold Storage only objects that are large and kept for months.