Skip to content

OSW — Object Storage Wrapper

By Romi Fadli, S.Kom. 12 min read v1.1.0 Storage & Data
On this page
A browser window connected directly to a storage bucket by a thick green band, while an orange box with a key above them hands the browser a dashed link.

OSW — Object Storage Wrapper — is a small HTTP service, written in Rust, that stands between applications and S3-compatible object storage. Applications never hold storage credentials and never stream file bytes through their own servers: they ask OSW for short-lived signed links, the browser uploads or downloads directly against storage, and OSW manages what happens to a file afterwards — committing it, deleting it softly, restoring it, or copying it between buckets.

I built it once and reuse it across projects, including the backend of this website, because every one of them needs the same few things from file storage, and getting those few things right is harder than it looks. Talking to storage directly spreads credentials into every application, sends every file the long way through an application server, and leaves each application to reinvent the lifecycle a file needs.

At a glance

Language & frameworkRust, with actix-web and a pure-Rust S3 client over rustls
ArchitectureClean Architecture
Version1.1.0
StatusStable, in use
FormA standalone HTTP service behind an API gateway, reused across projects
Source codePrivate
What the module is and where it stands.

What it does

A presigned URL is a temporary link, signed with the storage credentials, that allows one specific upload or download without handing over the credentials. OSW issues them for upload, download, or both, and names every new object itself, so the browser talks to storage directly and OSW never carries a byte of a file.

B. Gives each file a lifecycle

A file uploaded while a user fills in a form is not yet part of anything, and a deleted file should usually be recoverable for a while. OSW models both as a place on the path, inside the same bucket: a fresh upload lands in a temporary area, committing it moves it to its permanent path, a soft delete moves it to a deleted area, and a restore moves it back. Abandoned uploads are swept by the storage server itself, through a lifecycle rule that expires the temporary area.

C. Copies between buckets on the server

An application can move a file from a private bucket to a public one — for instance when an article's attachment becomes public along with the article — or copy it within one bucket, optionally removing the source. The copy happens inside the storage server; no byte passes through OSW or the application.

A page that lists many files can ask for all their download links in one request, optionally checking first that each file exists, and receives one row per requested path, in the same order.

E. Keeps one contract for every caller

Every response, success or failure, arrives in the same JSON envelope with a request id, every input error is reported at once, and the messages applications already parse stay word for word as they were.

Interface

CapabilityPurpose
Presignsigned upload and/or download links for new objects, with generated names
Commitmove an uploaded object to its permanent path
Existswhether an object exists, with its type, size, date, and tag
Downloada signed download link for one object
Batch downloadsigned download links for many objects in one request
Deletedelete an object, softly by default
Restorebring a softly deleted object back to its original path
Copycopy an object to another bucket or key, optionally removing the source
Livenessfor the container platform
OSW's HTTP interface, documented as an OpenAPI specification.

OSW can be mounted under any route of a gateway. A batch request is the one that carries a JSON body:

json
{
  "bucket": "archive",
  "paths": ["reports/2026/q3.pdf", "reports/2026/q4.pdf"],
  "verify_exists": true
}

Standards

StandardWhat it guarantees here
AWS Signature Version 4, query authenticationsigned links that storage accepts and that expire on time
S3 API: HeadObject, CopyObject with x-amz-copy-source, DeleteObjectexistence checks, server-side copies, and deletes on any S3-compatible server
Path-style and virtual-host addressingworks with MinIO behind a proxy as well as with hosted S3
UUIDv7object names and request ids that never collide and sort by time
RFC 3339link expiry times any client can parse
W3C Trace Context and OpenTelemetry (OTLP, semantic conventions)traces, logs, and metrics that continue across services
OpenAPI 3.0.3a machine-readable contract for every endpoint
TLS through rustlsencrypted connections without OpenSSL in the code path
The standards OSW follows, and what each one guarantees.

Inside

OSW follows Clean Architecture: storage rules in the domain, use cases grouped by resource, and S3, HTTP, configuration, and telemetry each behind a port or confined to their own layer. I rebuilt it into that shape layer by layer from an earlier version, in nine phases, and the last phase proved the published API specification byte-identical to the old one, so no application had to change. Clock and id generation are ports too, which makes every timestamp and name in a test predictable, and its 98 tests run without a storage server because each use case receives an in-memory storage that records every call.

Design decisions

Only OSW holds the keys, and links are signed for the address the browser will use. No application ever sees the storage credentials. A signature covers the host name, and the host OSW uses inside the network is usually not the one a browser can reach, so links are signed against the public origin the browser will use. The public origin is checked when the service starts, so a bad value fails at boot rather than on the first upload. The handles for the public origin are built once per bucket at start-up, because building one loads the system's trust store — about 305 microseconds of blocking disk work — while signing itself takes about 14, and a batch of 100 would have paid the first cost 100 times. The content type of an upload is placed inside the signed query rather than a signed header, so the browser does not have to repeat it exactly yet cannot change it, and path-style addressing carries over to the public handles so links stay correct behind a proxy. New objects are named {directory}/{uuidv7}.{extension} by OSW itself, so names never collide, sort by time, and cannot be chosen or overwritten by a client; the extension comes from a table of content types with a sensible fallback. A permission flag issues only the links a caller needs — upload, download, or both — and the link lifetime is configurable, with the expiry time returned so clients know when to ask again.

A file's state is visible in its path, and every change checks first. Uploads are temporary unless the caller says otherwise. Committing and restoring are a copy inside the storage server followed by a delete, so no bytes move however large the file. Paths are validated strictly and the object is looked up before any change, so a missing file is a clean 404 rather than a storage error. Deleting is soft by default and can be undone. OSW keeps no state and runs no sweeper: abandoned uploads are the storage server's job, done by its lifecycle rule.

Defaults are safe where they are harmless, and absent where they would hide a mistake. The buckets OSW may touch are exactly the buckets it is configured with, and it refuses to start with none. An unknown bucket is the caller's error, a 400, not a server fault. Single-file requests fall back to a default bucket, but a copy must name both buckets, because silently falling back would hide the caller's mistake.

A copy between buckets is a real server-side copy, and it never loses the source. The S3 client OSW uses can copy only within one bucket, so a cross-bucket copy presigns a PUT against the target with a signed x-amz-copy-source header and sends it through one reused HTTP client. That internal link never leaves the process. The header is sent as well as signed, or the signature would not match; the source key is encoded segment by segment, with spaces as %20 and never +, which storage would read as a literal plus. A copy that answers 200 with an error inside its body counts as a failure, because S3 can report a late copy failure that way. The source is never deleted when the copy failed; when the copy succeeded but deleting the source did not, the answer is still 200 with source_deleted: false — the backend team that asked for the endpoint wanted that, so a client does not repeat a copy that already happened. The target key defaults to the source key, copying within one bucket is allowed, and every validation message names the side it is about. TLS is rustls only, with the HTTP types pinned to the version the S3 client expects.

A batch fails only where it has to. The batch endpoint was added beside the single-file one after consumers worked around it by firing one request per file at once, which only moved the load onto OSW. A problem that affects every row — an unknown bucket, too many paths, an empty list — fails the whole request; a problem with one row fails only that row, and every other link is still returned, in the order requested, with duplicates echoed once per occurrence:

json
{
  "full_path_file": "reports/2026/q4.pdf",
  "download_url": null,
  "expired_at": null,
  "error": "not_found"
}

Every row carries the same four keys with explicit nulls, holds either a link or an error and never both, and uses a small vocabulary owned by the domain: not_found, invalid_path, and unavailable, the last kept apart so that a passing storage hiccup is retried rather than treated as a missing file. Whether each file is checked first has no default, because checking costs one storage request per file and skipping it loses the "missing" signal. Checks run with bounded concurrency, a limit kept separate from the cap on paths because the two exist for different reasons, so one caller cannot crowd out the others. One expiry time is computed for the whole batch, row errors are logged where they happen, and each row reuses the single-file path rules.

One contract, kept exactly. Every response is wrapped in one envelope, which replaced a dozen near-identical helpers. The JSON body limit is set on purpose, and malformed JSON still receives the envelope instead of the framework's plain-text error. Validation collects every problem before answering. Use-case errors are types that the HTTP layer maps to status codes, replacing an older trick that read prefixes of error strings, and a 500 says only which operation failed, with the cause in the log. The 401 body is the one deliberate exception, kept in its old shape for clients that already read it, as are the messages those clients parse. A missing object reported by the S3 client in its own inconsistent way is still recognised as a 404.

Identity comes from the gateway, and every request can be traced. OSW sits behind an API gateway that authenticates every caller and passes on only verified identity. A request id is accepted when the caller sends one and generated otherwise, then echoed back. Telemetry moved from writing straight to Elasticsearch to OpenTelemetry through a collector: incoming traces are continued, metrics use delta temporality because the collector's Elasticsearch exporter otherwise drops histograms and counters, HTTP metrics are labelled by route pattern rather than raw path, the client address comes from forwarded headers only when they arrive from trusted proxies, sensitive keys are redacted from logs, and telemetry can be switched off entirely, with a flush on shutdown when it is on. Configuration is read once, at start-up.

It ships small and runs unprivileged. The image is built in two stages, runs as a non-root user, and reads its configuration from a mounted file.

Specifications

InputsRequests from applications behind an API gateway, carrying verified identity
OutputsSigned links with expiry times, object metadata, lifecycle and copy results, all in one JSON envelope
StorageAny S3-compatible server, MinIO in my deployments, with a lifecycle rule that expires abandoned uploads
ObservabilityOpenTelemetry traces, logs, and metrics over OTLP, or standard output only
Tests98, without a storage server
Runs asA container image built in two stages, as a non-root user, with configuration mounted from a file
What OSW takes, returns, and needs.

Before OSW is put in front of a storage server, that server is checked with this script.

Proving the storage behind OSW is ready before anything depends on it.

OSW is the case study of this article about code that the next reader can follow.

The template that brings up an object storage server together with OSW for a new project.

Where OSW's traces, logs, and metrics go once they leave the service.

Terms

Object storage
Storage that keeps files as whole objects in named buckets, reached over HTTP.
Presigned URL
A temporary link, signed with storage credentials, that allows one specific upload or download without giving the client those credentials.
Soft delete
Moving a file to a deleted area instead of destroying it, so that it can be restored.
Lifecycle rule
A rule configured on the storage server that expires or moves objects automatically, for example everything under a prefix after one day.
API gateway
A service in front of other services that authenticates callers and forwards only verified identity to them.
Port
An interface the inner layers define and the outer layers implement, so the rules never depend on a particular tool.

Technologies covered

  • Rust
  • MinIO
  • S3

About the author

Photo of Romi Fadli

Romi Fadli, S.Kom.

Solution Architect · Backend Engineer · Embedded Systems

In my day job I work as a solution architect. I design the system, lead the team that builds it — quality assurance, backend developers, a UI developer, DevOps and DevSecOps — and write the most critical modules myself, such as a certificate authority, a long-term PDF signer, and the core API of a personnel system.

I work across the whole stack that a running system actually needs. On the hardware side that means PCB design, microcontrollers, and embedded firmware — including RISC-V boards running FreeRTOS and Embassy, speaking MQTT over mutually authenticated TLS on a microcontroller with 64 KB of SRAM.

Most of my day-to-day work is software: Rust backends and API modules built on Clean Architecture, an OAuth2/OpenID Connect authorization server written from scratch, PAdES document signing, and certificate authorities — root CAs, timestamping authorities, and OCSP responders. I also build the frontends that sit on top of them.

Running all of that means infrastructure: Docker Compose stacks, networking, Mikrotik and RADIUS, observability with OpenTelemetry, and a steady habit of writing command-line tools and utilities for Linux when the existing ones do not fit.

Share

Enjoyed this?

Get an email when a new article is published.

Referenced by

Employees and administrators use the Web App, which talks to the Core API; the Core API uses storage, regions, face and preview services, sends documents to the Aurograf signing platform, sends logs and traces to the log stack, and imports from the old system

SIMPEG

Programming

The personnel information system of the City Government of South Tangerang: employees keep their own records and follow their services, administrators work on exactly their part of the organisation, and every rule comes from the regulation that sets it. Led and built by me since 2023.

10 min read Read more →
The core API at the centre, fed by storage, address, face and preview services, with documents sent across a boundary to an external signing platform

The Laravel core of a civil-service personnel system: employee records and career histories, self-service proposals with review, salary-step, promotion and retirement decisions, rules taken from regulations, a versioned organisation chart, and access scoped down to a single unit.

11 min read Read more →
A central core holding people, organisations, documents, and certificates, connected to eight surrounding services for storage, addresses, certificates, timestamps, the root, signing, thumbnails, and face challenges

The Laravel core of a document-signing platform: it owns users, identities, organisations, client systems, documents and their signed versions, stamps, and certificates, and orchestrates the services that sign, timestamp, render, and store — without ever holding a file.

9 min read Read more →
Three camera frames of a face, facing forward, turned right, and turned left, each checked off, flowing into locked private storage that a reviewer looks into

A Python service that asks a person on camera to face forward, turn right, and turn left, captures one sharp frame per pose, and keeps the three as private evidence for a human reviewer — proving a live person was present, not who they are.

10 min read Read more →
A signed PDF with a QR stamp, wrapped in four nested frames for the signature, its timestamp, the embedded validation data, and the archive timestamp

A Python service that signs PDFs to PAdES B-LTA with a visible stamp — embedding the timestamp, certificates, and revocation proof, then an archive timestamp — so signatures stay verifiable long after their certificates expire, and verifies signed PDFs.

10 min read Read more →
PDF pages of different sizes passing through a renderer that reads from and writes back to storage, producing thumbnails of one uniform size

A stateless Rust service that renders the first page of a PDF into a sharp, uniformly sized WebP thumbnail, reading the PDF and writing the image through two presigned links, so no application streams files or holds storage credentials.

8 min read Read more →
A purple root circle linked to a blue and an orange intermediate circle, which lead to a signed document with a green seal and to a clock.

Aurograf

DevOps

The digital signature platform of the City Government of South Tangerang: signers prove who they are, sign PDFs to PAdES B-LTA with certificates from the platform's own authorities, and anyone can verify the result — version 2, which I designed, built and operate.

10 min read Read more →

Related Articles

A four-level tree of Indonesian regions, with one chosen path coloured level by level, feeding a form of four cascading dropdowns in the same colours

A Rust service that serves Indonesia's administrative regions — 38 provinces down to 83,768 villages, keyed by the official Ministry of Home Affairs codes — as four cascading lookups with search, so every address form in every project gets the same answer.

10 min read Read more →
Four concentric circles with arrows coming in from a database, a browser window, and a message queue, all pointing toward the centre.

Clean Architecture

Programming

Clean Architecture is one of the architectures I use: why I chose it, the folder and naming conventions every module of mine follows across Rust, Python, Dart, Node.js and C, the questions I check a module against, and where I use something else instead.

12 min read Read more →