Building wearlunaire.com
Eleventh post in “Tools I build with.” The product is configured on Printful, the API token works, and the brand has a name and a font. None of that is a website. This post is the day wearlunaire.com goes from a parked domain to a live one-page hero with a reservation form and a working backend. The whole thing runs on the same VPS that already hosts eight other projects, in the same nginx container that serves maloudongelmans.com, with no new infrastructure introduced.
Reuse over rebuild
The cheapest, fastest, and most boring way to ship a new site is to put it on the server you already run. Episode 02 set up one Hostinger VPS that hosts every project in this journal. Adding wearlunaire.com to it adds zero new monthly cost and zero new things to remember to maintain. The whole stack already exists: Traefik for routing and SSL, an nginx container serving static files, a small Python backend for the contact form, and Resend for email. Wearlunaire fits inside that, not next to it.
This is the boring secret of solo-built brands. Every project after the first is a delta on a stack that already works, not a new stack. A new container, a new database, a new managed service for every new site is how a one-person operation drowns in upkeep. The point of the markdown-pyramid skill (episode 03), the service-account pattern (episode 04), and the nginx-per-VPS pattern (episode 02) is the same: reduce the number of things that can go wrong and the number of places to look when they do.
One nginx, two virtual hosts
The existing maloudongelmans-website-1 nginx container holds maloudongelmans.com on a catch-all server_name _ block. Adding wearlunaire.com is a second server block in the same nginx config, with its own document root and its own restricted set of paths.
server {
listen 80;
server_name wearlunaire.com www.wearlunaire.com;
root /usr/share/nginx/html/lunaire;
index index.html;
# Brand assets and fonts shared with the parent site
location ^~ /img/lunaire/ { alias /usr/share/nginx/html/img/lunaire/; }
location ^~ /fonts/ { alias /usr/share/nginx/html/fonts/; }
# Anything outside the lunaire scope returns 404 cleanly
location / { try_files $uri $uri/ =404; }
}
The ^~ prefix locations matter. They alias /img/lunaire/ and /fonts/ back to the parent site’s asset directories so the brand pack and BDO Grotesk are served from one place rather than copied. The =404 on the catch-all stops anything outside /usr/share/nginx/html/lunaire from leaking, so paths like /journal/ or /about.html intentionally 404 on wearlunaire.com instead of accidentally rendering maloudongelmans content under the wrong domain.
The hero is one job
The wearlunaire.com homepage does one thing: a full-viewport autoplay video, a wordmark overlay, and a quick-pick reservation form. Below the fold there is nothing. The first iteration had three sections (the hero, an “about” band, a small product description), and they all got cut. A waitlist landing page is a CTA wrapped in atmosphere. The atmosphere should not have to share screen with explanation.
Specifications for V1, all temporary:
- V1.mp4, the hero loop, autoplay-muted-loop, currently 27MB. Mobile-hostile and the first thing to re-encode before any marketing push (a 1080p H.264 pass should land it around 6 to 8MB without visible quality loss).
- The L monogram top-left, generated from the brand pack’s dark monogram by mapping luminance to alpha and repainting in white, so the antialiased edges survive on top of moving video.
- The LUNAIRE wordmark in Heavitas, set against the video at the centre.
- The tagline (Get the limited edition sweatshirt) and a quick-pick form: a number input (1 to 999) and a size dropdown (S to 2XL). Submitting the quick-pick navigates to
/reserve/?n=042&s=M. - Two text links to the socials (@wearlunaire on Instagram, @wear.lunaire on TikTok, the dot is intentional, the platforms differ on handle availability).
Honest about the trade. A 27MB hero on a phone over 4G stalls. The fix exists, the launch did not wait for it. The journal records launch state.
The reservation page does the actual work
Click Reserve on the homepage and you land on /reserve/: a full form with the chosen number and size pre-filled, plus name, email, country, and an optional note. Submitting POSTs to /api/reserve on the same origin. On success the page replaces the form with a confirmation: the reserved number rendered large in Heavitas, a one-line acknowledgement, no celebration animation. The number is the receipt.
The form deliberately does not charge a card. Lunaire ships without a checkout in V1 because the unit-economics are still being verified (episode 12 explains why the digitisation fee changes the price model), and because a waitlist that asks for an email is a softer ask than a waitlist that asks for €79.99. The order of operations is: collect interest, validate the pipeline against real reservations, then wire Stripe Checkout. Episode 12 covers the wiring decisions; payment goes live after.
This is the pragmatic version of an art-edition release. The buyer claims a number, the seller fulfils manually, the trust is one-handshake-deep. It works at a small scale because it has to: a real factory wholesale order would not work this way, but 999 sweatshirts at one launch’s worth of demand is exactly the scale where a personal pipeline beats an industrial one.
The /api/reserve backend, reused
The contact form on maloudongelmans.com (episode 7) already runs on a small FastAPI container, maloudongelmans-contact, with Resend wired up for transactional email and a per-IP rate limiter. Adding /api/reserve to that container is a new route on a backend that already exists. No new container, no new database, no new secret to rotate.
The route validates the inputs (number 1 to 999, size in {S, M, L, XL, 2XL}, email via Pydantic’s EmailStr), formats an HTML email with the reservation details, and sends it to the founder address via Resend. The submitter’s email goes into the Reply-To header so a one-line response goes straight back to the buyer. CORS is opened for https://wearlunaire.com and https://www.wearlunaire.com, although the form posts same-origin so this is defensive rather than necessary.
What is not in the V1 backend, on purpose:
- No uniqueness check on the reserved number. Two people can request
#042; the manual reply resolves the collision. Once early demand arrives, a smallreserved_numbers.jsonstore and a/api/reserve/checkendpoint will surface taken numbers in the form. Until there is real demand, the manual reply is cheaper than the engineering. - No payment. The waitlist is the entire mechanism in V1. Stripe Checkout wires in next.
- No CRM. Reservations live in the founder’s inbox, threaded by email. A real CRM lands when the inbox stops being enough.
The discipline is to push state into the manual reply until the manual reply hurts. Then engineer the bit that hurts. This is a small operation and it gets to behave like one.
Traefik routing and Let’s Encrypt
Traefik (episode 02) is the reverse proxy that already sits in front of every container on the VPS. Adding wearlunaire.com is three new routers in /root/traefik-config/dynamic.yml:
wearlunaire, on HTTPS, forwarding to the existing nginx upstream172.17.0.1:8084, with TLS via the existing Let’s Encrypt resolver.wearlunaire-http, on HTTP port 80, redirecting to HTTPS so the apex domain does not serve plain HTTP.wearlunaire-reserve, also HTTPS, on the same hosts but a path prefix of/api/reserve, forwarding to the contact backend container instead of nginx. This is what splits same-origin traffic between static and dynamic.
The Let’s Encrypt cert issues automatically the first time Traefik reloads the config and resolves the new hostname through the HTTP-01 challenge. No manual cert handling. From DNS-pointed-at-VPS to HTTPS-live is around five minutes including DNS propagation.
What to be aware of
- Hero video size matters more than people remember. A 27MB autoplaying loop is fine on a desktop with broadband and broken on a mid-range phone over 4G. Re-encode before any traffic source pushes mobile users at the page. The tooling is one
ffmpegcommand. - Two domains in one nginx is fine; isolate them strictly. The catch-all
=404on wearlunaire.com is what stops accidental cross-leaks. Without it, a bad path on wearlunaire could fall through to maloudongelmans content with the wrong canonical, the wrong OG tags, and the wrong analytics property. The strict 404 is a feature. - Same-origin posts beat CORS. Both the static page and the
/api/reserveendpoint live on wearlunaire.com via different Traefik routers. The browser sees one origin, the form posts simply, no preflight. CORS only needs configuring if a third-party domain ever calls the API, which it should not. - One container per concern, but not one container per site. Each project in this journal earns a container only when it has a distinct runtime. Static sites share the nginx container; small APIs share the FastAPI contact container; daily cron jobs share the host crontab. Pre-mature container splitting is a tax on the operator.
- Waitlist as V1 is a deliberate choice, not a workaround. Skipping payment in V1 lets the launch ship in days rather than weeks, lets the unit-economics get pinned down with real reservations rather than guesses, and lets the buyer claim a number with no friction. Wiring Stripe Checkout costs a session, not a month, and is the right next step rather than the missing first step.
What’s next in the series
- Wiring it all together. The end-to-end pipeline from form submit to Printful draft order, with a real test that lands in the dashboard.
- Marketing without a budget. The hardest part of the whole launch.
Subscribe below for the next one.