Skip to main content

S3 object storage

Object storage replaces an application's local disk: images, attachments, videos, generated PDFs, exports. Files leave the application server for a bucket, addressed by a URL and reachable from any instance of the application.

Bunker provides S3-compatible storage, backed by Ceph RadosGW and hosted in Europe. Any AWS SDK (boto3, aws-sdk-js, flysystem-s3, minio-go) works unmodified, only the endpoint and the keys change.

What you get​

You get one bucket, one S3 user who owns it, and one quota. There is nothing else to administer.

SettingValue
Endpointhttps://s3.france-nuage.fr
URL stylepath-style (https://s3.france-nuage.fr/<bucket>/<key>)
Region to signdefault
SignatureAWS Signature V4
Bucketthe name agreed when ordering
Quotathe agreed size, hard-enforced
Underlying storageCeph RadosGW, 3 replicas across 3 datacenters

The endpoint above is the one shipped with your credentials today, and your handover always restates it.

Credentials (access key and secret key) are delivered separately, never in a reference document. The secret key is displayed once. If it is lost, we issue a new one.

The S3 user you receive owns that bucket and nothing else. It can neither create other buckets (which would bypass the quota) nor read anyone else's. We verify this isolation at delivery.

Connecting​

Two mandatory settings

The SDK has to force path-style and sign on region default. Without the first, it tries https://<bucket>.s3.france-nuage.fr and finds nothing. Without the second, signing fails with SignatureDoesNotMatch. Those two omissions account for most failed first connections.

Here is the configuration to copy, in Python with boto3:

import boto3
from botocore.config import Config

s3 = boto3.client(
"s3",
endpoint_url="https://s3.france-nuage.fr",
region_name="default",
aws_access_key_id="...",
aws_secret_access_key="...",
config=Config(s3={"addressing_style": "path"}, signature_version="s3v4"),
)

The same thing with the AWS CLI:

aws --endpoint-url https://s3.france-nuage.fr --region default \
s3 ls s3://my-bucket/

And for rclone, in the ~/.config/rclone/rclone.conf file:

[bunker]
type = s3
provider = Ceph
endpoint = https://s3.france-nuage.fr
region = default
force_path_style = true
access_key_id = ...
secret_access_key = ...

The three access modes​

Three needs call for three distinct mechanisms, which coexist in the same bucket.

NeedMechanismWhere to store the fileWho can read
Images, videos, public assetsPermanent linkunder public/anyone with the exact URL
Sensitive documentsPresigned, expiring URLanywhere except public/whoever holds the link, until expiry
Documents of evidentiary valueObject Lock (WORM)anywhere except public/the application only

On request, we open one key prefix for anonymous read, public/ by convention. Any object whose key starts with that prefix then becomes readable by anyone who knows its URL, with no signature and no expiry:

https://s3.france-nuage.fr/my-bucket/public/media/8f3c1e2a-photo.jpg

That URL can go into an <img src>, an email or a PDF. It never expires.

Two guardrails come with that opening. Anonymous bucket listing stays denied, including on the public prefix, so files cannot be enumerated and you need the exact URL. Anonymous writes stay denied too, so nobody can drop or erase anything.

By default a bucket has no public prefix at all, everything in it is private. Opening one is a request, and the prefix is yours to choose.

For a sensitive document, the application generates a signed URL valid for N seconds. After that it returns 403. There is nothing to enable, signing is native and the operation stays purely local to the SDK, with no network call and no cost to generate.

url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "my-bucket", "Key": "private/reports/2026-09/report-1183.pdf"},
ExpiresIn=900, # 15 minutes
)

Signature V4 caps validity at 7 days. Beyond that, the URL is rejected.

The same mechanism works for writes with put_object, which allows a direct browser-to-storage upload without routing the file through the application server. That is the recommended mode for large files and videos.

Immutable objects (WORM)​

Object Lock makes an object version indestructible until a deadline. The application decides, object by object, what becomes immutable and for how long:

s3.put_object(
Bucket="my-bucket",
Key="reports/2026-09/report-1183.pdf",
Body=pdf,
ObjectLockMode="COMPLIANCE", # or GOVERNANCE
ObjectLockRetainUntilDate="2031-09-25T00:00:00Z",
)

Two modes exist, and the choice matters:

  • GOVERNANCE forbids deleting the version, except through an explicit call carrying the x-amz-bypass-governance-retention header. This is the immutability that protects against a slip and against an application bug.
  • COMPLIANCE forbids deletion to everyone before the deadline, you included, Bunker included, cluster administrator included. This is the immutability you can hold up against a third party.
Object Lock is enabled at bucket creation, never after

The constraint comes from the S3 protocol itself, and a bucket created without Object Lock can never gain it. If WORM is a requirement, even a future one, ask for it when ordering the bucket. Otherwise a second bucket and a full copy are the only way out.

COMPLIANCE is irrevocable the other way too. A retention date set too far out, ten years on a test file for instance, pins that space until the deadline, and that space keeps counting against the quota. Validate on GOVERNANCE before switching to COMPLIANCE in production.

The key convention: public/ is public​

If you have a public prefix opened, this is the one structural rule to hold in code, and no automatic guardrail protects it.

The public/ prefix is anonymous-readable, everything else in the bucket is private. A sensitive file written to public/ by mistake becomes readable by anyone who knows its URL, immediately and with no trace. Since URLs of that kind circulate by design (emails, PDFs, web pages), that sort of leak is hard to walk back.

A typical layout:

public/media/<id>.jpg            ← readable by everyone
public/videos/<project>/<id>.mp4
private/<client>/<id>.pdf ← presigned only
reports/<yyyy-mm>/<id>.pdf ← presigned + WORM

Two implementation recommendations:

  1. Centralize key construction in a single function that takes visibility as an explicit parameter, rather than letting every caller concatenate a path.
  2. Put an unguessable identifier in the key, a UUID rather than photo-1.jpg. With anonymous listing closed, an unpredictable key makes the contents of public/ undiscoverable other than through the link you handed out.

If the public/ boundary does not match your data model, it can move, one line of configuration on our side. Better to settle it before writing the code.

Before you write the code​

Four behaviours surprise anyone who has not anticipated them.

Versioning comes with Object Lock, permanently​

WORM requires versioning, and once enabled it cannot be turned off. Overwriting a file therefore does not replace the old one, it archives it, and old versions count against the quota. A lifecycle rule purges them after 30 days, which incidentally leaves you a 30-day recovery window after an accidental overwrite or deletion.

Deleting does not delete​

On a versioned bucket, a delete_object call without a version ID places a delete marker. The object disappears from listings and GETs, but the bytes remain and keep counting against the quota until the deferred purge. To erase for real and right away, delete the specific version through its VersionId.

This is also the limit of WORM, which prevents destruction without preventing concealment. An object under retention can be hidden behind a delete marker, while the protected version stays intact and recoverable.

Large files go multipart​

Past a few tens of MB, SDKs split the upload. An interrupted upload leaves fragments that consume quota without appearing in a normal listing, and that is the classic cause of "the bucket is full even though it's empty". A lifecycle rule aborts them automatically after 7 days.

The quota is not enforced to the second​

Bucket usage stays cached for about ten minutes, so a slight overshoot is possible before writes are refused. Once the ceiling is reached, PUTs return 403 QuotaExceeded. Handle that case explicitly in the application rather than discovering it in production.

If the browser talks to the storage directly, for a presigned upload from the front end or a video read through fetch, you need a CORS configuration listing your domains. None is set by default. Send us the origins and we set it.

Migrating existing files​

Moving a few tens of GB from a local disk poses no technical difficulty, the transfer takes tens of minutes. Everything hinges on the order of operations.

The safest path takes four steps:

  1. Copy without switching over. The rclone copy or aws s3 sync command pushes files from the production server to the bucket, application unchanged, placing each file under the right prefix (public or not). That is the moment to decide.
  2. Have the application read both sources, object storage first and local disk as a fallback when the object is missing. At this stage a file missed by the copy breaks nothing.
  3. Replay the sync to catch what was written to disk during steps 1 and 2, then switch writes over to object storage.
  4. Only free the disk afterwards, once the step-2 fallback has become useless. Its hit rate should drop to zero.

The initial copy runs through the following command:

rclone copy /var/www/myapp/storage bunker:my-bucket/private \
--transfers 8 --progress

Two things deserve attention.

Do not enable WORM retention during the migration. A resumed copy rewriting an already locked object would fail, and in COMPLIANCE mode the first copy would be indelible even if it is wrong. Apply retention after verification, on the objects that need it.

Watch the quota during the copy as well. If the bucket is versioned, a copy restarted from scratch archives the previous one instead of replacing it, and two full passes consume double until the deferred purge. The rclone copy command only transfers what differs, which avoids the problem, whereas an rclone sync --delete-before run blind causes it.

If the quota turns out to be tight after the migration, the ceiling goes up through one line of configuration, with no service interruption.

Limits and monitoring​

SettingValuePurpose
Bucket quotathe agreed sizewrites refused beyond it
Fill alert80% of quotanotifies the Bunker on-call
Old version purge30 days (if versioned)bounds the cost of versioning
Incomplete upload abort7 daysbounds multipart fragments
Creating other bucketsforbiddenprevents bypassing the quota
Redundancy3 replicas, 3 datacenterssurvives the loss of a site

The fill alert goes to the Bunker on-call. If you want to be notified directly, tell us where to send it.

What is not covered​

Worth stating plainly.

  • The bucket is not backed up elsewhere. The 3 replicas protect against a hardware failure or the loss of a datacenter, while doing nothing against a deletion on the application side. The only safety net is the deferred version purge, which leaves 30 days to recover an accidental deletion on a versioned bucket. After that, the object is gone. If the files warrant it, a backup is added explicitly.
  • Client-side encryption is not provided. Data is encrypted in transit through TLS and the cluster disks are encrypted too, but the keys belong to the platform. End-to-end encryption is therefore not in place, and an infrastructure administrator can technically read the objects. If some documents require it, your application has to encrypt before upload.
  • No CORS is configured by default, to be set as soon as the browser talks to the storage directly.
  • There is no per-object access log. We cannot say today who downloaded which file, or when. Ask for it if a traceability requirement appears.

Reversibility​

Nothing here locks you in, and that comes from how the service is designed.

The API is the S3 one, with no proprietary extension. Code written for this bucket runs unmodified against self-hosted MinIO, another Ceph or another provider, only the endpoint and the keys change. Presigned links, Object Lock, lifecycle and policy are all public S3 mechanisms.

All of the data exports with a standard tool, without our involvement:

rclone sync bunker:my-bucket /local/destination --progress

Two caveats deserve saying. Objects under COMPLIANCE retention copy without trouble, but they cannot be deleted from the source before their deadline, which is exactly what that protection promises and what an exit plan has to account for. And bucket configuration (policy, lifecycle, Object Lock) does not follow the data automatically. It is version-controlled on our side as files, which we provide on request so they can be replayed as-is elsewhere.

Next steps​