If you've ever looked at a marketplace's fee page and felt your eye twitch, this post is for you.
The major selling platforms take their cut from every angle — transaction fees, listing fees, monthly subscriptions, payment processing. The percentages vary but the direction doesn't: a meaningful slice of every sale goes to infrastructure you don't own or control. And that's before the visibility problem: on a marketplace of millions of listings, the algorithm decides whether your work gets seen at all.
My girlfriend had been listing her work on one of the big marketplaces for a while — barely any traffic, zero sales. The fees were almost beside the point. I saw the disappointment and floated the idea: her own site, her own corner of the internet — and I'd build it.
I'm a software engineer. I like a challenge. So I set out to give it a shot. The result runs at €3.29/mo for all backend infrastructure. The site is live at nadiapoe.co.uk. Here's the stack and the decisions behind it.
The constraints
Artwork loads instantly. Watercolours are the product. A 2-second LCP would send visitors away before they saw a painting. No image-CDN that stamps a watermark on the hero image, no spinner while the page wakes a cold serverless function.
No third-party watermarks. Image-CDN convenience — Cloudinary, imgix — isn't worth a logo in the corner of the hero. This is an artist's portfolio. The paintings deserve respect.
Real commerce, not a payment link. Multi-currency (GBP / EUR / USD / AUD), international fulfillment for prints, originals shipped from the UK. A Stripe payment link in the Instagram bio wasn't going to cut it.
No tracking, no cookie banner. The site doesn't follow visitors. No analytics cookies, no third-party pixels, no consent popup to dismiss before you can see a painting. Purchase data goes only as far as it needs to: card details to Stripe, a shipping address — originals are shipped by us from the UK, prints via a print-on-demand provider for international orders. Nothing retained beyond what's needed to get the order out the door.
Indie budget. One artist, no team, no investor. Anything more than ~£10/month total infrastructure is a recurring tax on the creative work. That ceiling shaped every hosting decision in the stack.
The stack, piece by piece
The infrastructure splits cleanly across two providers.
Cloudflare (free tier)
Everything the visitor touches runs on Cloudflare. CDN and WAF sit in front of everything — Bot Fight Mode, rate limiting, R2 hotlink protection. Behind them:
Cloudflare Pages hosts the Astro 6 storefront. Static by default, per-route SSR where needed (checkout callbacks, contact form, structured data feeds). The painting pages are pure HTML — the entire collection ships zero JS until the cart is opened.
Cloudflare R2 stores all images and process videos. Zero egress fees, served from a custom domain (media.nadiapoe.co.uk). Videos are pre-encoded to four variants locally with ffmpeg (720p desktop, 480p mobile, JPEG poster, 64×64 thumb) and uploaded via wrangler. No URL transforms, no image-service quotas to exhaust.
Cloudflare D1 is the edge SQL database — one table, one purpose: like counts. The like button stores your choice in localStorage and increments a counter in D1. No cookies, no tracking, no consent popup. You see the count; nothing sees you.
Hetzner VPS — €3.29/mo
Everything commerce-related runs on a single Hetzner CAX11: 2 vCPU ARM64, 4 GB RAM, 40 GB NVMe, 20 TB/month egress. On it:
- Medusa v2 — the commerce backend. Dev and prod as separate systemd services, both reverse-proxied by Caddy.
- PostgreSQL — orders, products, customers.
- Redis — BullMQ event bus and workflow engine.
Render, AWS, and Oracle Always Free each failed for a different specific reason. Hetzner just works.
One gap in Medusa v2 worth knowing: there's no official Resend provider. Order confirmation emails skip the notification module entirely and call the Resend SDK directly from an order.placed subscriber.
Third-party services
Stripe handles payments across four currencies (GBP / EUR / USD / AUD). Cloudflare injects cf.country into a <meta> tag at the edge; the storefront reads it to pick the matching Medusa region and present prices in the local currency. User override persists in localStorage.
Resend handles transactional email — 3,000 emails/month free, then $1/1,000.
The interactive layer
Svelte islands handle the cart drawer, quantity controls, painting gallery, and region selector. Nano-stores (cartStore, cartUpdating, regionStore) keep islands in sync without a framework router.
The shop grid itself is static Astro HTML — no Svelte involved. The paintings are the product; a visitor should see the full collection immediately, not wait on an inventory check before anything renders. So every card defaults to "available," then a plain <script> tag calls the Medusa API in the background and patches data-status on each card to flip sold originals to a faded state. No layout shift, no spinner, no framework overhead for a read-only grid.
One Astro 6 gotcha that cost an afternoon: server-side secrets must come from import { env } from 'cloudflare:workers'. The old Astro.locals.runtime.env was removed and import.meta.env silently bakes undefined for server-only vars. No build error, no runtime warning — just missing data in production.
What it actually costs
Backend hosting: €3.29/mo — both staging and production Medusa instances, PostgreSQL, Redis, Caddy, automated nightly backups to Cloudflare R2. Frontend, CDN, edge SQL, object storage, WAF, analytics: free tier. The metered costs are Stripe (1.5–2.9% per transaction — unavoidable regardless of platform, but at least there's no extra platform cut on top) and Resend (3,000 emails/month free, then $1/1,000).
Compare that to a typical marketplace taking 6–7% on a £150 original watercolour — that's £9–10 per sale, forever, to infrastructure you don't own. At even modest volume the self-hosted setup pays for itself inside the first month.
Will this survive a traffic spike? Honestly, no idea — this shop has never been Slashdotted, and a 2 vCPU ARM box with 4 GB RAM is not going to win any load test. But if it ever buckles under the weight of people trying to buy original watercolours, upgrading the VPS will be the easiest problem on the list that day.
One snippet worth stealing
This pattern only works because the HTML is served through a Cloudflare Worker — but if you're already on Cloudflare Pages, you have that for free.
The full CSP is set per-request with a fresh nonce, injected into every <script> tag at the edge. No build-time hash dance, no list of inline script hashes to maintain. From client/src/middleware.ts:
export const onRequest = defineMiddleware(async (context, next) => {
const response = await next()
if (typeof HTMLRewriter === 'undefined') return response
if (!response.headers.get('content-type')?.includes('text/html')) return response
const nonceBytes = crypto.getRandomValues(new Uint8Array(16))
const nonce = btoa(String.fromCharCode(...nonceBytes))
const rewritten = new HTMLRewriter()
.on('script', { element(el) { el.setAttribute('nonce', nonce) } })
.transform(response)
const headers = new Headers(rewritten.headers)
headers.set('Content-Security-Policy', buildCsp(nonce))
return new Response(rewritten.body, { status: rewritten.status, headers })
})
Cloudflare's own bot-fight challenge scripts get whitelisted automatically — they read the nonce from the CSP header and stamp themselves with it. On a static-only setup you're stuck with hashes, and those break the moment Cloudflare rotates a challenge token. The Worker approach sidesteps that entirely.
The site is nadiapoe.co.uk if you want to see the result.
Top comments (0)