Supabase, Mistral, Bunny, Mollie, Brevo, Scaleway, Coolify, GlitchTip — eight weeks, €8/month infra, no AWS, no Stripe, no Vercel. What worked, what didn't.
I shipped Linkette — an EU-sovereign link-in-bio for creators — in 8 weeks, solo, on a stack with zero US sub-processors. Total monthly infrastructure cost: €8. This is the postmortem.
The shopping list
| Layer | Service | Region | Monthly cost |
|---|---|---|---|
| Database + auth | Supabase | Paris | Free tier |
| LLM inference | Mistral La Plateforme | Paris | Pay-per-use |
| CDN + storage | Bunny.net | Slovenia | ~€0.50 |
| Payments | Mollie | NL | Pay-per-tx |
| Transactional email | Brevo | Paris | Free tier |
| App hosting | Scaleway VPS DEV1-S | Paris | €5.99 |
| Deploy + reverse proxy | Coolify (self-hosted) | (on VPS) | Free |
| Error tracking | GlitchTip (self-hosted) | (on VPS) | Free |
| LLM observability | Langfuse Cloud EU | Frankfurt | Free tier |
The €8 is the VPS plus a Bunny pull-zone for avatar storage. Everything else is on a free tier or pay-per-use.
Supabase Paris vs Postgres-on-Scaleway
I considered running Postgres directly on a Scaleway DBaaS instance and skipping Supabase. The DBaaS would have been cheaper at idle (€7/mo for the smallest managed Postgres). I picked Supabase anyway, for three reasons:
- Auth. Supabase Auth handles magic-link email, OAuth, JWTs, RLS-aware session cookies, refresh tokens, and the
@supabase/ssrcookie dance for Next.js Server Components. Building that in 8 weeks would have meant building nothing else. - RLS is the security model. Linkette is multi-tenant by
user_id. Postgres RLS lets the database itself enforce tenant isolation. Every policy is auditable from the migration file. I literally cannot leak another user's data without first rewriting the RLS — which is a much higher bar than "the route handler remembered to filter." - The Supabase Paris region. Same region as Mistral, same region as my VPS. Total round-trip from a Linkette server-action: page-load query → Postgres → response ≈ 8ms. That is faster than the JS engine can render.
The trade-off: free tier ends at 500MB of database storage, 1GB of file storage, 50MB of database backups. Linkette is well under that today. The honest answer is I'll get pushed to Supabase Pro at €25/mo before I hit the next infra bottleneck, and that's fine.
A representative migration — the analytics events table with its RLS policies:
-- supabase/migrations/20260517142434_analytics_events.sql
create table public.events (
id bigserial primary key,
page_id uuid not null references public.pages(id) on delete cascade,
kind text not null check (kind in ('view', 'click')),
link_id uuid references public.links(id) on delete set null,
ip_hash text,
country text,
region text,
city text,
ua_class text check (ua_class in ('mobile', 'tablet', 'desktop', 'bot') or ua_class is null),
referrer_host text,
created_at timestamptz not null default now()
);
create index events_page_created_idx on public.events(page_id, created_at desc);
create index events_link_created_idx on public.events(link_id, created_at desc) where link_id is not null;
create index events_page_kind_created_idx on public.events(page_id, kind, created_at desc);
alter table public.events enable row level security;
create policy events_insert_anon on public.events
for insert to anon, authenticated
with check (true);
create policy events_select_own on public.events
for select to authenticated
using (page_id in (select id from public.pages where user_id = auth.uid()));
Anonymous can insert (it's a public page's tracking event). Only the owner can read. The DB itself enforces it; no application bug can ever expose a competitor's analytics.
Bunny CDN: why not Cloudflare
The default 2026 answer to "I need a CDN" is Cloudflare. I didn't pick it. Three reasons:
- Cloudflare is US-headquartered. Even with EU-only routing and a DPA in place, the CLOUD Act in principle applies. For the "EU-sovereign" claim to be honest, this matters.
- Bunny is dramatically simpler. A pull-zone is two clicks. Storage zones live in 12 EU PoPs. The dashboard explains what every setting does in plain English. No "Workers vs Pages vs Functions" decision tree.
- Cost. €0.005/GB egress in Europe. Linkette's monthly egress is in the single GBs. I pay around 30 cents.
Bunny isn't perfect — image transformations are not in their core CDN, and their "Optimizer" add-on costs more than I'd like. For Linkette, avatars are pre-sized at upload (Bunny's Storage API handles the multipart accept), so I don't need on-the-fly transforms.
Mollie + the "first payment with sequenceType: first" trick
Mollie is the EU's Stripe. The flow for one-shot payments (the €99 Founding Member tier) is trivial. The flow for recurring subscriptions has one non-obvious step that I want to spell out, because the Mollie docs hide it inside the larger "create a customer, create a mandate, create a subscription" walkthrough.
The pattern: the first payment in a recurring sub uses sequenceType: "first" to capture both the charge AND the mandate in one step. After it clears, you create the actual Subscription resource against that mandate, and Mollie auto-charges on the cycle.
// apps/web/lib/billing/mollie.ts
// Mollie subscription flow:
// 1. Create a Customer for the user (one-time, store id).
// 2. Create a "first payment" with sequenceType: 'first' to capture a
// mandate. Customer pays €0.01–€1 to authorize; we charge the real
// subscription amount on cycle 1.
// 3. After the first payment is paid, the customer has a mandate. Create
// a Subscription using that mandate.
// 4. Mollie auto-charges on each cycle and POSTs the webhook with the
// new payment id.
//
// For Linkette we skip the €0.01 capture pattern and charge the FULL
// price (€6 or €54) as the first payment with sequenceType='first'.
// That payment also seeds the mandate; the subscription starts on the
// next cycle from that date.
export async function createFirstSubscriptionPayment(input: {
customerId: string;
amount: MollieAmount;
description: string;
redirectUrl: string;
webhookUrl: string;
metadata?: Record<string, unknown>;
idempotencyKey?: string;
}): Promise<MolliePayment> {
return mollieFetch<MolliePayment>(
`/customers/${encodeURIComponent(input.customerId)}/payments`,
{
method: "POST",
body: JSON.stringify({
amount: input.amount,
description: input.description,
redirectUrl: input.redirectUrl,
webhookUrl: input.webhookUrl,
sequenceType: "first",
metadata: input.metadata,
}),
idempotencyKey: input.idempotencyKey,
},
);
}
The trick that saves you a step: instead of the documented "charge €0.01 to capture the mandate, then start the subscription," charge the full first month with sequenceType: "first". You get the user paid up immediately AND you get the mandate. The subscription then starts cycling from cycle 2.
One catch the docs underplay: never trust the webhook body. Mollie POSTs only the payment id; you must fetch the payment via the REST API to get the canonical status. The fetch is authenticated with your API key, which the webhook caller doesn't have. This is a deliberate security pattern — a hostile actor can't forge a "paid" webhook.
// In the webhook handler:
const payment = await getPayment(paymentIdFromBody);
if (payment.status !== "paid") return new Response("OK", { status: 200 });
// ... only now grant the entitlement
The Mollie webhook handler in Linkette also uses idempotency keys on the create call, so cron retries don't duplicate-charge:
idempotencyKey: `${userId}-${tier}-${attemptStamp}`,
Mollie dedupes same-key requests within 24 hours, which is plenty for our retry loop.
Brevo: free tier covers a lot
Brevo (the French ex-Sendinblue) handles two things: Supabase magic-link emails (configured at the Supabase auth-settings level via SMTP credentials) and our weekly digest sends. The free tier gives 300 emails/day — enough for a multi-thousand-user beta before paying.
The send helper is a thin wrapper:
// apps/web/lib/email/brevo.ts
const BREVO_ENDPOINT = "https://api.brevo.com/v3/smtp/email";
export async function sendEmail(input: SendEmailInput): Promise<{ ok: boolean; error?: string }> {
const apiKey = process.env.BREVO_API_KEY;
if (!apiKey) {
console.warn("[email] BREVO_API_KEY not set — skipping send");
return { ok: false, error: "Email not configured" };
}
const sender = {
name: process.env.EMAIL_FROM_NAME ?? "Linkette",
email: process.env.EMAIL_FROM_ADDRESS ?? "hello@linkette.eu",
};
try {
const res = await fetch(BREVO_ENDPOINT, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
"api-key": apiKey,
},
body: JSON.stringify({
sender,
to: [{ email: input.to }],
subject: input.subject,
htmlContent: input.html,
textContent: input.text,
replyTo: input.replyTo ? { email: input.replyTo } : undefined,
headers: input.unsubscribeUrl
? {
"List-Unsubscribe": `<${input.unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
: undefined,
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
console.error("[email] Brevo send failed", res.status, body);
return { ok: false, error: `Brevo ${res.status}` };
}
return { ok: true };
} catch (err) {
console.error("[email] Brevo send threw", err);
return { ok: false, error: "Email send failed" };
}
}
The List-Unsubscribe + List-Unsubscribe-Post headers light up Gmail and Apple Mail's native unsubscribe button. Required by French CPCE L34-5 anyway. Details in the GDPR checklist piece.
Scaleway VPS Comfort, and the OVH SSH-key trap
I started on OVH's VPS Starter. Walked into the OVH SSH-key trap: OVH's web UI lets you upload public keys for VPS provisioning, but the keys are only injected at first boot via cloud-init, which doesn't run on minimum-spec instances. So you upload your key, you spin up the VPS, you ssh in… password-only. Add to that OVH's billing flow making you re-validate your identity for every spend ceiling change, and I switched providers within 48 hours.
Scaleway DEV1-S (the entry VPS) at €5.99/mo: 2 vCPU, 2GB RAM, 20GB SSD, Paris region. Cloud-init works. The dashboard shows a real terminal. Their CLI (scw) is a joy. I will never go back.
Coolify on a single VPS instead of Vercel/Kubernetes
The hosting decision was the one I agonized over longest. The choices were:
- Vercel — what every Next.js dev defaults to. US company, hosting in EU regions available, but the build pipeline runs in the US.
- Kubernetes on Scaleway — appropriate for ~500 microservices, not for a Next.js app on day one.
- Coolify on a single VPS — open-source self-hosted PaaS, Docker under the hood, push-to-deploy from GitHub.
I picked Coolify. The setup:
- One Scaleway DEV1-S running Ubuntu 24.04.
- Coolify installed (one curl command).
- Connect a GitHub repo, paste env vars, click "Deploy".
- Coolify builds with the Dockerfile in the repo, runs
next starton its standalone output, fronts it with Traefik for TLS via Let's Encrypt.
The Dockerfile:
# apps/web/Dockerfile
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@11.1.2 --activate
WORKDIR /repo
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc tsconfig.base.json ./
COPY apps/web/package.json apps/web/
COPY packages/tokens/package.json packages/tokens/
COPY packages/ui/package.json packages/ui/
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /repo /repo
COPY . .
RUN pnpm --filter @linkette/web build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
COPY --from=build /repo/apps/web/.next/standalone ./
COPY --from=build /repo/apps/web/.next/static ./apps/web/.next/static
COPY --from=build /repo/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["node", "apps/web/server.js"]
next.config.ts has output: 'standalone' so the build emits a self-contained Node bundle. Image weight: ~190MB. Cold start: ~1.2s. Memory at idle: ~210MB. The DEV1-S has plenty of headroom.
Coolify gives you per-app logs, env management, manual rollback, custom domains, the works. The whole "deploy by git push" feels exactly like Heroku in 2012 and I mean that as the highest praise.
GlitchTip self-hosted (Sentry-API compatible)
I want errors. I don't want to pay Sentry's per-event pricing. GlitchTip is an open-source Sentry-compatible error tracker — same wire protocol, same DSN format, same @sentry/nextjs SDK works against it without changes.
// apps/web/sentry.server.config.ts
import * as Sentry from "@sentry/nextjs";
// Server-side Sentry init. Lazy-loaded by instrumentation.ts when
// SENTRY_DSN is set. Target = GlitchTip on the OVH VPS (Sentry-API
// compatible), so the same SDK works without changes.
//
// Trade-offs:
// - tracesSampleRate: 0 — no perf tracing on day 1 (would 10x the
// storage burn on GlitchTip).
// - replaysSessionSampleRate: 0 — no session replay.
// - profilesSampleRate: 0 — Node profiling not supported on Alpine.
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.SENTRY_RELEASE,
tracesSampleRate: 0,
beforeSend(event, hint) {
const err = hint?.originalException;
const msg = err instanceof Error ? err.message : String(err);
if (/Unauthorized|Too Many Requests|AbortError/i.test(msg)) return null;
return event;
},
});
GlitchTip runs alongside Coolify on the same VPS via Docker Compose. Same domain, different subdomain (errors.linkette.eu). The beforeSend filter drops noise from expected 4xx and abort errors — critical, otherwise GlitchTip drowns in user-cancelled streaming requests.
Honest trade-offs: no session replay (GlitchTip OSS doesn't store them), no perf tracing (the storage cost on a single VPS is brutal), no profiling. For a one-person SaaS this is fine.
What I'd do differently
Probably skip Coolify, use Dokku
Coolify's UI is genuinely nicer than Dokku's. But Coolify is more moving parts (Postgres + Redis + Traefik + the Coolify app itself) on a small VPS than I'm comfortable with long-term. Dokku is a much thinner layer over Docker + Nginx + Let's Encrypt. If I started over I might pick Dokku for the smaller blast radius, and accept the worse UI. As it stands, Coolify hasn't given me a single ops incident in 8 weeks, so I haven't moved.
Skip the standalone Next build at first
Next.js's output: "standalone" is great for Docker image size, but it took me an evening to debug why my monorepo wasn't picking up the packages/ui files into the standalone bundle (you need outputFileTracingRoot: path.join(__dirname, "../..") in next.config.ts). On day one I should have shipped the non-standalone build, accepted a fatter image, and optimized later.
Don't put GlitchTip on the same VPS as the app
When errors are most informative — production crashing — the GlitchTip instance is at risk of being crashed too. Run it on a separate small VPS. Cost difference: €6/mo. Worth it.
Things I tried that didn't work
Hetzner instead of Scaleway
Hetzner is cheaper than Scaleway at every tier. I tried it for a day. The friction killers: identity verification took 12 hours, the dashboard was awkward, the German-by-default UI confused me into provisioning the wrong machine. Scaleway's French-by-default UI is, ironically, easier for me than Hetzner's German.
Self-hosting Mistral via vLLM
Covered in the Mistral piece — H100s are way too expensive for one user, hosted Mistral is already in Paris, no benefit.
Plausible self-hosted on the same VPS
I almost ran Plausible alongside Coolify and GlitchTip for the dashboard analytics. The reason I dropped it: the data was going to live in Postgres anyway (to feed the weekly Mistral brief), and running a second analytics database doubled the storage cost and the operational surface. Collapsed everything into a single events table with a SQL function that returns the dashboard summary as JSON.
The 8-week timeline
For context:
- Week 1: scaffold (Next.js + Supabase + auth + magic-link)
- Week 2-3: data model + RLS + the actual link-in-bio editor
- Week 4: Mistral integration + weekly brief
- Week 5: Tailwind v4 migration + Atelier design system + 10 themes
- Week 6: analytics + GDPR/AI-Act compliance pass
- Week 7: Mollie billing + Founding Member tier + Atelier subscription
- Week 8: marketing site + blog + observability polish + launch
Nothing in this stack is locked-in. I could move the app to a Hetzner VPS in an afternoon, swap Bunny for any S3-compatible CDN, switch the email vendor with a env-var change. The whole point of staying on small, well-scoped EU vendors is that you stay portable.