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.
| Setting | Value |
|---|---|
| Endpoint | https://s3.france-nuage.fr |
| URL style | path-style (https://s3.france-nuage.fr/<bucket>/<key>) |
| Region to sign | default |
| Signature | AWS Signature V4 |
| Bucket | the name agreed when ordering |
| Quota | the agreed size, hard-enforced |
| Underlying storage | Ceph 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
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.
| Need | Mechanism | Where to store the file | Who can read |
|---|---|---|---|
| Images, videos, public assets | Permanent link | under public/ | anyone with the exact URL |
| Sensitive documents | Presigned, expiring URL | anywhere except public/ | whoever holds the link, until expiry |
| Documents of evidentiary value | Object Lock (WORM) | anywhere except public/ | the application only |
Permanent link
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.
Temporary link (presigned URL)
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:
GOVERNANCEforbids deleting the version, except through an explicit call carrying thex-amz-bypass-governance-retentionheader. This is the immutability that protects against a slip and against an application bug.COMPLIANCEforbids 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.
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:
- Centralize key construction in a single function that takes visibility as an explicit parameter, rather than letting every caller concatenate a path.
- Put an unguessable identifier in the key, a UUID rather than
photo-1.jpg. With anonymous listing closed, an unpredictable key makes the contents ofpublic/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:
- Copy without switching over. The
rclone copyoraws s3 synccommand 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. - 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.
- Replay the sync to catch what was written to disk during steps 1 and 2, then switch writes over to object storage.
- 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
| Setting | Value | Purpose |
|---|---|---|
| Bucket quota | the agreed size | writes refused beyond it |
| Fill alert | 80% of quota | notifies the Bunker on-call |
| Old version purge | 30 days (if versioned) | bounds the cost of versioning |
| Incomplete upload abort | 7 days | bounds multipart fragments |
| Creating other buckets | forbidden | prevents bypassing the quota |
| Redundancy | 3 replicas, 3 datacenters | survives 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
- External backups, to point a NAS at the same storage and apply the 3-2-1 rule
- SFTPGo file transfer, to expose a bucket over SFTP, FTPS or WebDAV to partners