DEV Community

Cover image for The Zero-Budget Stack: Architecting for the “Free Tier”
Senthil Kumar MS
Senthil Kumar MS

Posted on Originally published at phpscientist.com on

The Zero-Budget Stack: Architecting for the “Free Tier”

You can run a production-grade MVP or personal site on free tiers if you design for their limits. Serve the front end statically from Cloudflare Pages or GitHub Pages. Put small APIs on edge functions and use a free managed PostgreSQL within its storage cap. Back everything up on a schedule, and avoid proprietary SDKs so you can switch providers with a single push.

This guide shows the full stack, how to set each part up with working code, and when it is time to start paying. If you are still choosing your core technologies, start with the best tech stack for building a SaaS application and the best database for SaaS applications.

Key takeaways

  • Serve the front end statically so your site stays online even when the backend sleeps or hits a limit.
  • Use standard protocols everywhere (HTTP, SQL, S3-compatible storage, SMTP) so any provider can be replaced in an afternoon.
  • Free databases have small storage caps and may pause when idle. Design lean schemas and schedule backups from day one.
  • Cache at the edge, paginate every list and batch background work; efficient code is what keeps you inside free limits.
  • Start paying when cold starts cost conversions, when you near a cap, or when you need backups, support and uptime guarantees.

Why the Free Tier Is a Serious Architecture Target

Free tiers used to be a toy: a sleeping container and a database that vanished after a month. In 2026 they are good enough to carry real traffic. A static site on a global CDN, a few edge functions and a small managed PostgreSQL can serve tens of thousands of visitors a month without a bill.

The catch is that every free plan is defined by its limits: requests per day, storage, build minutes, idle timeouts. You do not get to ignore them by adding hardware. You have to design around them. That makes a zero-budget stack a useful discipline even for teams with money, because the habits it forces (caching, lean queries, portable infrastructure) are exactly the ones that keep paid systems cheap and fast later.

The Strategy of Elastic Limits

Maintaining a site on free services requires a shift in how we view resource management. Instead of throwing hardware at a problem, we optimize for efficiency. Three principles guide every decision in this stack:

  • Static first: anything that can be built ahead of time is served as static files. Platforms like Cloudflare Pages and GitHub Pages serve static assets from a CDN with generous or unlimited bandwidth, so your pages stay up even if the backend is asleep or rate-limited.
  • Compute at the edge, in small pieces: forms, redirects and small APIs run as short-lived functions that cost nothing when idle.
  • Data is precious, storage is scarce: free PostgreSQL plans usually cap storage at around 0.5–1 GB. Normalization isn’t just “good practice” here; it’s a survival tactic.

A Reference Zero-Budget Stack

This combination stays portable because every layer speaks an open standard. The allowances below are approximate and change often, so treat them as a guide rather than a contract.

LayerFree optionTypical free allowanceStandard it speaksFront endCloudflare Pages or GitHub PagesStatic hosting with generous bandwidthHTTP, GitEdge logicCloudflare WorkersAbout 100,000 requests per dayFetch API (web standard)DatabaseSupabase, Neon or Render PostgreSQLRoughly 0.5–1 GB; may pause or expire when idlePostgreSQL wire protocol, SQLMediaCloudflare R2About 10 GB stored, no egress feesS3 APICI/CD and cronGitHub ActionsFree for public repos; monthly minutes for privateYAML workflows, shellEmailResend or BrevoA few thousand emails per monthHTTPS API or SMTPMonitoringA free uptime checker plus platform logsChecks every few minutesHTTP

Check the current limits

Free plans change several times a year. Before you commit, read each provider's pricing page and terms, and note what happens at the limit: some throttle, some return errors, some pause your project and some start billing.

How to Build the Stack, Step by Step

The steps below build a small site with a contact form, a PostgreSQL-backed API and nightly backups. Each step works on its own, so you can adopt only the parts you need.

Step 1: Deploy the static front end

Build your site with any static generator (Astro, Hugo, Eleventy or a plain HTML folder) and deploy the output directory. With Cloudflare Pages you can deploy straight from the command line:

# Build the site, then upload the output folder
npm run build
npx wrangler pages deploy dist --project-name=my-site

# Later: connect the Git repository in the dashboard so every push deploys automatically
Enter fullscreen mode Exit fullscreen mode

Static hosting removes the most common failure of free backends: a sleeping server. Visitors get your pages instantly from the CDN; only interactive features touch compute.

Step 2: Handle forms and small APIs at the edge

A contact form does not need a server that runs all day. A Worker wakes up for each request, validates the input and calls an email API. This one validates the fields, rejects obvious spam with a hidden honeypot field and sends the message through Resend:

export interface Env {
  RESEND_API_KEY: string
  CONTACT_TO: string
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== 'POST') return new Response('Method not allowed', { status: 405 })

    const form = await request.formData()
    const name = String(form.get('name') ?? '').trim().slice(0, 100)
    const email = String(form.get('email') ?? '').trim()
    const message = String(form.get('message') ?? '').trim().slice(0, 5000)

    // Bots fill in every field, including the hidden one
    if (form.get('website')) return new Response('OK')
    if (!name || !message || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      return new Response('Please fill in every field.', { status: 422 })
    }

    const res = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        from: 'Website <contact@example.com>',
        to: env.CONTACT_TO,
        reply_to: email,
        subject: `Contact form: ${name}`,
        text: message,
      }),
    })
    return new Response(res.ok ? 'Thanks, message sent.' : 'Sending failed, please try again.', { status: res.ok ? 200 : 502 })
  },
}
Enter fullscreen mode Exit fullscreen mode

Store the API key as a secret (npx wrangler secret put RESEND\_API\_KEY), never in the repository. The same pattern covers redirects, webhooks and small JSON APIs.

Step 3: Connect PHP to a free PostgreSQL database

Free managed PostgreSQL works with plain PDO, so your application code stays provider-neutral. Read a single DATABASE\_URL from the environment, always require TLS, and keep connections short:

<?php
declare(strict_types=1);

function db(): PDO
{
    static $pdo = null;
    if ($pdo instanceof PDO) {
        return $pdo;
    }

    $url = parse_url((string) getenv('DATABASE_URL'));
    $dsn = sprintf(
        'pgsql:host=%s;port=%d;dbname=%s;sslmode=require',
        $url['host'],
        $url['port'] ?? 5432,
        ltrim($url['path'], '/')
    );

    $pdo = new PDO($dsn, urldecode($url['user']), urldecode($url['pass']), [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]);

    return $pdo;
}
Enter fullscreen mode Exit fullscreen mode

Free plans allow only a small number of direct connections. If your provider offers a connection pooler (Supabase and Neon both do), point DATABASE\_URL at the pooled endpoint. Moving to another provider later means changing one environment variable.

Step 4: Deal with cold starts and idle pauses

Many free web services spin down after about 15 minutes without traffic, and some free databases pause after days of inactivity. The first visitor afterwards waits while the service wakes up. You have two options.

The better option is to design so cold starts rarely matter: static pages, edge functions that start in milliseconds, and a backend that is only called for interactive actions. The fallback is a scheduled ping from GitHub Actions that keeps the service warm:

# .github/workflows/keep-warm.yml
name: Keep warm
on:
  schedule:
    - cron: '*/10 7-22 * * *'   # every 10 minutes during your audience's daytime (UTC)
  workflow_dispatch:
jobs:
  ping:
    runs-on: ubuntu-latest
    steps:
      - run: curl --fail --silent --max-time 30 https://api.example.com/health
Enter fullscreen mode Exit fullscreen mode

Read the terms before you ping

Keep-alive pings use up monthly free hours, and some providers restrict them in their terms. Limit pings to the hours your visitors are awake, and stop if your provider asks you to. GitHub may also disable scheduled workflows in repositories with no recent activity.

Step 5: Schedule database backups to object storage

Free database plans rarely include point-in-time recovery, and some expire or delete idle projects. A nightly pg\_dump to R2 costs nothing and protects you from both:

# .github/workflows/backup.yml
name: Nightly database backup
on:
  schedule:
    - cron: '30 2 * * *'
  workflow_dispatch:
jobs:
  backup:
    runs-on: ubuntu-latest
    steps:
      - name: Install PostgreSQL client
        run: sudo apt-get update && sudo apt-get install -y postgresql-client
      - name: Dump and upload
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: auto
        run: |
          FILE="backup-$(date -u +%Y-%m-%d).sql.gz"
          pg_dump --no-owner --no-privileges "$DATABASE_URL" | gzip > "$FILE"
          aws s3 cp "$FILE" "s3://my-backups/db/$FILE" \
            --endpoint-url "https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
Enter fullscreen mode Exit fullscreen mode

Make sure your pg\_dump version is at least as new as the server's PostgreSQL version. Add a lifecycle rule on the bucket to delete backups older than 30 days, and test a restore once into a local database. A backup you have never restored is only a hope.

Design for the Limits in Your Code

Free tiers reward efficient code. These habits keep you well inside the limits and lower your bill later, when the free tier is behind you.

Cache at the edge

Every request the CDN answers is a request your backend never sees. Send explicit cache headers from PHP for anything that does not change per user:

<?php
// Public JSON that can be a few minutes stale
header('Content-Type: application/json');
header('Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600');
echo json_encode(latestPosts(), JSON_THROW_ON_ERROR);
Enter fullscreen mode Exit fullscreen mode

s-maxage tells the CDN to keep the response for five minutes, and stale-while-revalidate lets it serve the old copy while it fetches a new one. Never cache responses that contain personal data or set cookies.

Make every query cheap

Small databases feel fast when every query is targeted. Index the columns you filter and sort on, and use keyset pagination instead of large OFFSET values, which force the database to read and discard every skipped row:

-- Index the columns used to filter and sort
CREATE INDEX IF NOT EXISTS posts_published_idx ON posts (published_at DESC, id DESC)
  WHERE status = 'published';

-- Keyset pagination: pass the last row of the previous page
SELECT id, title, published_at
FROM posts
WHERE status = 'published'
  AND (published_at, id) < (:last_published_at, :last_id)
ORDER BY published_at DESC, id DESC
LIMIT 20;
Enter fullscreen mode Exit fullscreen mode

Watch for the N+1 query problem too: loading a list and then running one query per row multiplies your database time and connection use. Load related rows in one query with a join or an IN list.

Shrink media and batch background work

  • Media: serve images as AVIF or WebP at the sizes you actually display. That saves bandwidth, storage and build time.
  • Background jobs: run reports, imports and clean-ups as scheduled jobs instead of keeping an always-on worker alive.
  • Storage hygiene: delete expired sessions, old logs and soft-deleted rows on a schedule so you don't drift towards the storage cap.

The Lock-in Trap

The danger of “free” is the proprietary hook. Services like Firebase make it incredibly easy to start, but their specific SDKs make it incredibly hard to leave. As a scientist, I advocate for standardized protocols. Use Docker-ready services. Use vanilla PostgreSQL. Use standard PHP-FPM. If a free provider changes their terms, you should be able to migrate your entire stack with a single git push.

A simple test: can you run the whole application on your laptop with no cloud accounts? A small Compose file proves it and doubles as your exit plan:

# compose.yaml: the same stack, locally, with no cloud accounts
services:
  app:
    build: .
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
    ports: ['8080:8080']
    depends_on: [db]
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes: ['pgdata:/var/lib/postgresql/data']
volumes:
  pgdata:
Enter fullscreen mode Exit fullscreen mode

Keep a short portability checklist in your README:

  • All configuration comes from environment variables, never from provider-specific config files.
  • Database access uses SQL and migrations you own, not a vendor's client-side SDK.
  • File storage goes through the S3 API, so R2, S3, Backblaze B2 and MinIO are interchangeable.
  • Infrastructure settings (DNS, Workers, buckets) live in code, for example a wrangler.toml file or Terraform.

Monitoring on a $0 Budget

You still need to know when something breaks. Add a /health endpoint that checks the database with a trivial query, point a free uptime checker at it, and send alerts to email or a chat channel. Use your platform's built-in logs for errors, and add a free error tracker plan if you need stack traces. Keep an eye on usage dashboards too: a surprise traffic spike is good news only if it does not trip a limit.

Common Free-Tier Problems and Fixes

  • The database paused or was deleted: some free projects pause after a week of inactivity, and some free databases expire after a fixed period. Keep backups, and know each provider's idle policy.
  • “Too many connections” errors: use the provider's connection pooler and close connections promptly.
  • The first request is slow: move public pages to static hosting and treat the backend as an API for interactive features.
  • Contact emails land in spam: verify your sending domain and set up SPF, DKIM and DMARC records.
  • Builds stop running: monthly build minutes ran out. Skip builds when only documentation changes, and cache dependencies in CI.

Know When to Start Paying

Free tiers are a launchpad, not a contract. Limits and terms change, so keep your infrastructure in code and your backups current. Start paying when one of these becomes true:

  • Cold starts or rate limits are costing you sign-ups or sales.
  • You regularly use more than about 70% of a storage or request limit.
  • You need point-in-time recovery, support or an uptime commitment for customers.
  • Your time spent working around limits is worth more than the plan that removes them.

Because the stack is portable, upgrading is usually a plan change, not a migration. That is the real payoff of designing for the free tier.

The Scientist’s Take

Architecture is the art of working within constraints. Building a high-performance system on a $0 budget isn’t just about saving money—it’s a stress test for your logic. If your code is efficient enough to run on a free tier, it will be a powerhouse when you finally scale to paid infrastructure.


Originally published at phpscientist.com.

Top comments (0)