Devlog 11: Going Live - Deploying to Fly.io With Wildcard Subdomains

7 min read
deployment infrastructure scaling

Devlog 11: Going Live - Deploying to Fly.io With Wildcard Subdomains

After weeks of localhost development, it was finally time to deploy for real. I needed a platform that could:

  1. Support wildcard subdomains (so cats-vs-dogs.tuggy.io works)
  2. Scale globally (low latency everywhere)
  3. Handle viral traffic spikes
  4. Not bankrupt me

I went with Fly.io.

Why Fly.io?

I looked at Vercel, Railway, and Render. They're all great, but Fly.io had one killer feature: native wildcard domain support. Each matchup can be matchup-name.tuggy.io instead of ugly query params or paths.

Plus they have edge locations worldwide, auto-scaling, and a generous free tier. The CLI is also really nice.

Wildcard DNS Setup

First I needed to set up DNS for wildcards:

*.tuggy.io  →  CNAME  tuggy.fly.dev
tuggy.io    →  A      66.241.124.100

Now any subdomain routes to my Fly app. The app has to figure out which matchup to load based on the hostname.

Subdomain Routing in SvelteKit

In my hooks.server.ts, I extract the matchup slug from the subdomain:

export async function handle({ event, resolve }) {
  const host = event.request.headers.get('host') || '';

  // cats-vs-dogs.tuggy.io → slug = "cats-vs-dogs"
  if (host.includes('.tuggy.io') && !host.startsWith('www')) {
    const slug = host.split('.tuggy.io')[0];
    event.params.slug = slug;
  }

  return await resolve(event);
}

So cats-vs-dogs.tuggy.io and tuggy.io/cats-vs-dogs both work identically. Users can share either URL format.

Fly.io Configuration

My fly.toml is pretty straightforward:

app = "tuggy"
primary_region = "sjc"

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 1

[auto_scaling]
  min_machines = 1
  max_machines = 10

This means:

  • Start with 1 machine always running
  • Scale up to 10 during traffic spikes
  • Automatically stop idle machines to save money
  • Force HTTPS for security

Multi-Region Deployment

I deployed to 6 regions for global coverage:

fly regions add sjc lax iad fra nrt syd

That's US West, US East, Europe, Asia, and Australia. Users automatically hit the nearest region, which cuts latency significantly.

The tradeoff is that my Supabase database is only in US West, so European users have higher write latency. But the vote batching helps a lot here. 1-2 second delay is fine for global sync.

Caching Trending Matchups

The homepage shows trending matchups, which I cache for 30 seconds to avoid hitting the database on every page load:

const CACHE_TTL = 30;
const cache = new Map();

export async function load({ setHeaders }) {
  const cached = cache.get('trending');

  if (cached && Date.now() - cached.timestamp < CACHE_TTL * 1000) {
    setHeaders({ 'cache-control': `public, max-age=${CACHE_TTL}` });
    return cached.data;
  }

  const data = await fetchTrendingMatchups();
  cache.set('trending', { data, timestamp: Date.now() });
  return data;
}

This simple in-memory cache cut database load by ~95% on the homepage.

Deployment Pipeline

I set up GitHub Actions to auto-deploy on every push to main:

name: Deploy to Fly.io

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: flyctl deploy --remote-only
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

Now every merge to main automatically deploys to production. Scary but convenient.

Health Checks

Fly.io needs to know if the app is healthy. I added a simple health endpoint:

// src/routes/health/+server.ts
export async function GET() {
  const { error } = await supabase
    .from('matchups')
    .select('id')
    .limit(1);

  if (error) {
    return new Response('Unhealthy', { status: 503 });
  }

  return new Response('OK', { status: 200 });
}

If Supabase is down, the health check fails and Fly.io pulls the machine out of rotation.

First Viral Spike

About a week after launching, someone shared a matchup on Twitter and it blew up. Traffic went from ~10 concurrent users to over 500 in minutes.

Fly.io auto-scaled from 1 machine to 8 machines within 30 seconds. No downtime, no manual intervention. It just worked. The bill for that day was $12 (way cheaper than I expected).

Costs

Current monthly costs:

  • Fly.io: ~$30/month baseline, spikes during viral moments
  • Supabase: Free tier (for now)
  • Domain: $12/year

So I'm running this whole thing for about $30/month, serving thousands of users. Not bad.

Takeaway

Deployment is way easier than it used to be. Platforms like Fly.io abstract away so much complexity. I didn't have to configure load balancers, SSL certs, or orchestration. It just works.

The wildcard subdomain support was worth the price of admission alone. Being able to give each matchup its own URL makes sharing feel way more natural.

Next time I'm digging into the particle system and how I made click animations feel good.