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
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 <[email protected]>',
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 })
},
}
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;
}
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
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"
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);
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;
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:
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.tomlfile 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)