Devlog · Phase 0 · 3/7
Collecting Email Addresses Without a Single Table
aubia.dev collects email addresses without a database. Content is stored in flat files, and each email address is sent to aubia.cloud via a signed proxy.
- architecture
- security
- waitlist
- gdpr

aubia.dev's waitlist collects email addresses, and the repo serving it holds not a single table. Not one model, not one migration: the migrations directory doesn't even exist.
Taking the database out of a site removes a whole class of concerns in one stroke: backups, encryption at rest, data leaks, GDPR purges. You don't secure what you don't hold.
Each submitted email address crosses a worker's memory for the length of one outgoing HTTP call, then disappears. The service that receives it, confirms it and keeps it is aubia.cloud.
Personal data goes from the site to the cloud through one flow only. A signed proxy forwards the submission to the remote service, with a proof of origin computed on the spot.
Flat Files for All the Content
The site's copy is stored in JSON translation files, this blog's articles in Markdown versioned with the code. Those flat files are read straight off the disk, with no database server in front.
Six locales and no SQL query. The content ships with the repo, with no migration step.
Redis stays in place, for the session, a transient cache and the rate limiter. That last one caps how many submissions are accepted from one origin over a window of time. It works on an ephemeral hash of the email address and another of the IP address, which expire with the window they serve.
The cache has one exception: the blog index is written into it with no expiry date. It holds titles, dates and the already rendered HTML of public articles, no personal data.
The diagram simplifies a little: other flows cross the boundary, such as the public read of the confirmed counter. The signed proxy is the only one carrying personal data.
A Controller Without Writes
When you submit your email address, the controller stays thin. It validates, puts the fields into a typed object, delegates to an Action and returns a flash message.
It knows nothing of the network or of the signature, and it has no database to write to. The store() method does nothing else:
final class WaitlistController
{
public function store(SubmitWaitlistRequest $request, SubmitWaitlistEmail $action): RedirectResponse
{
$validated = $request->validated();
$data = WaitlistSubmissionData::fromArray([
'email' => $validated['email'],
'utm_source' => $validated['utm_source'] ?? null,
'utm_medium' => $validated['utm_medium'] ?? null,
'utm_campaign' => $validated['utm_campaign'] ?? null,
'rendered_at' => (int) $validated['rendered_at'],
'locale' => $validated['locale'],
]);
$status = $action->execute($data);
if ($status->isSuccess()) {
return back()->with('waitlist_success', $status->messageKey());
}
return back()->with('waitlist_error', $status->messageKey());
}
}
The email address exists in memory only, for the length of the request. No row is written on the site side.
The Shared Secret and the Anti-Robot Traps
The SubmitWaitlistEmail Action takes over. It serializes the fields to forward, then computes an HMAC SHA-256 signature of the resulting body, that is, a shared-key signature.
The Signature of the Outgoing Body
It posts to aubia.cloud, with an Origin header fixed by contract. The serialized body, the secret and the headers are built in this order:
$rawBody = json_encode($data->toCloudPayload(), JSON_THROW_ON_ERROR);
$secret = (string) config('services.cloud.signing_key');
$headers = [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Origin' => 'https://aubia.dev',
'X-Signature' => 'sha256=' . hash_hmac('sha256', $rawBody, $secret),
];
The signature is computed on the exact body that goes out, with a secret both sides know. The cloud replays the same computation and compares.
The secret never leaves those two servers. A bearer token, by contrast, travels on every call, and it can leak into the logs if it isn't redacted there.
Locally, the signature can be turned off behind a configuration flag, to test the form without standing up the whole secret chain. In production, the send fails if signing is off or the key missing, rather than posting in the clear and in silence.
Two Traps and an Excluded Field
The form stops robots with two traps a visitor never meets: a honeypot and a minimum delay. The article on the signup details them.
One of the two touches the outgoing contract. The rendered_at timestamp field serves local validation and never reaches the cloud, which keeps the contract surface minimal and stabilizes the signature.
The DTO's output method says so in its comment and applies it in its array:
public function toCloudPayload(): array
{
// rendered_at (piège temporel) volontairement exclu.
return [
'email' => $this->email,
'utm_source' => $this->utmSource,
'utm_medium' => $this->utmMedium,
'utm_campaign' => $this->utmCampaign,
'locale' => $this->locale,
];
}
The timestamp is the only field the DTO carries without passing it on to the cloud.
These two traps guard the form, and the form alone. A minimum delay is sidestepped by a careful robot, able to schedule a wait, and a honeypot by an automaton that reads the stylesheet.
The outgoing contract is guarded otherwise, by the shared secret. Without it, no request forged from outside gets accepted.
Retrying a Request Without Signing Anyone Up Twice
A naive HTTP client replays every failed attempt. The outgoing client's policy depends on what the site knows of the request's fate.
A response received, even a server error, proves the cloud got the request. What it did with it stays unknown, so the signup POST is never retried in that case.
A network failure brings back no response at all. The request most likely never reached the cloud, and that is the only case where it is retried.
So the outgoing client decides, request by request, whether to replay. Two executions are sometimes worth exactly the same as one.
The confirmed counter's GET retries in every case, server errors included, since re-reading a number signs nobody up. The signup POST replays only on a network error, and the condition is written in the client:
->retry(
$retries + 1,
fn(int $attempt): int => $backoffMs + random_int(0, 100),
// Retry UNIQUEMENT sur erreur réseau : aucune réponse reçue, et le cloud
// absorbe un doublon éventuel. Jamais sur une réponse reçue, même 5xx.
fn(Throwable $e, PendingRequest $request): bool => $e instanceof ConnectionException,
throw: false,
)
Only a ConnectionException triggers a new attempt. If the cloud answered, even with a server error, the site maps the response onto a business status and shows the matching message.
Consent on the Cloud Side
aubia.cloud generates the confirmation token, sends the email, expires the link after 48 hours and keeps the consent trail.
Until a signup is confirmed, no other communication goes out. Email addresses never confirmed are purged after 30 days, the duration the privacy page publishes.
The site's server never sees that token. The confirmation and unsubscribe pages pass it through the browser, in a direct call to the cloud, without coming back through it. The rest of the journey, from the email field to the confirmed counter, is told in another article.
Three Cookies and a Theme Key
Everything the server renders is identical for every visitor, with one exception: the root. / redirects to /fr, /en or another of the six locales, following a preference cookie then the Accept-Language header.
That cookie, aubia_locale, carries a locale code, nothing else. Two others go with it: XSRF-TOKEN for the CSRF token, which protects against submissions forged from another site, and aubia-session for the Laravel session.
The theme preference is stored in localStorage, under the aubia-theme key. localStorage is the space the browser reserves for one given site, and its content travels in no request.
These three cookies are technical, none of them measures audience or serves advertising. Exempt from consent, they spare the site a banner.
The Cost and the Gain of the Absence
A shared secret is declared in two repos, with its rotation to orchestrate. Every signup depends on the network. If aubia.cloud doesn't answer, the form says so, instead of writing somewhere while waiting for better days.
The confirmed counter has no table to query. It calls the cloud's public read endpoint, keeps the result in cache for a few dozen seconds and holds a last known value as an outage fallback.
Redis remains required for the session, the cache and the rate limiter. Some state persists, ephemeral.
On the other side, deployment runs no migration. The site has no backup to test, no encryption at rest to audit, no email address to purge. The token's 48 hours and the 30-day retention are counted on the cloud side, at the only place that holds anything.
The Limits of the Trade-Off
I choose between those two lists: one network dependency and one key to rotate on one side, four compliance workstreams that don't exist on the other.
This trade-off doesn't transpose to an application that has to serve its users when the network goes down. Nor would it hold on a site that grew until it held something other than public content.
On its current perimeter, the site needs no table.
This build is chronicled here, article after article. What comes next depends on what you have to say about it.
Join the waitlist