All lessons
Linux & serversAdvanced15 min readIn my stack

Production infrastructure: the best setup for each stage

Reference architectures from one well-run VPS to Kubernetes, and how to move between them safely.

  • Reference setup
  • Staging
  • Backups
  • Monitoring
  • Scaling path
Step 1/4
One server, a lot of users. CPU at 100%, everyone waits. Not great.

In 30 seconds

There is no single best infrastructure, only the right one for your current traffic, team and risk. Most products should start on one server done right, then move the database and files to managed services, then add a load balancer and more app servers, and only adopt containers orchestration when the team and scale truly need it. Across every stage the essentials are the same: separate environments, managed secrets, tested backups, monitoring with alerts, and migrations planned so no data is lost.

Why I use it

  • I run production for my own and my clients' Laravel and Next.js projects, from shared panel servers to a dedicated server for a multi-tenant SaaS, so I choose architecture by stage, not by fashion.
  • I have seen how much money and attention over-built infrastructure wastes on small projects, and how fragile under-built setups are without backups and monitoring.
  • I am currently consolidating servers myself, so the migration playbook in this lesson is the one I actually follow.

How it works

01Principles before diagrams

Good infrastructure is boring: predictable deploys, clear ownership, and failures that are noticed and recoverable. Before picking tools, answer four questions. How much downtime can the business tolerate (availability target)? How much data can it lose (RPO) and how fast must it recover (RTO)? Who will operate it at 3 a.m.? What is the monthly budget? Then grow in steps. Every step should remove a real bottleneck or risk you can name: the database fighting the app for memory, uploads stuck on one disk, a single server being a single point of failure, or a team deploying dozens of services. Moving to the next stage too early adds cost and complexity without benefit; moving too late means outages. Whatever the stage, keep infrastructure described in code or at least in written runbooks, so it can be rebuilt.

02Stage 1: a single VPS done right

For an MVP, a small business site or an internal tool, one well-configured server (2 to 8 GB RAM) is often the best choice: cheap, fast, easy to understand. nginx terminates HTTPS and routes to the apps; PHP-FPM and Node run under their own users; the database and Redis listen on localhost; workers run under supervisor or systemd. Docker Compose is a good option to package it all reproducibly. What makes it 'done right' is everything around the apps: the hardening baseline, Cloudflare in front, nightly database dumps copied off the server, provider snapshots, uptime and disk alerts, and a scripted deploy with release folders. Its weakness is that it is a single point of failure, so recovery speed matters: you should be able to rebuild it from scripts and backups in hours, and you should have practised that once.

  • Cloudflare (DNS, WAF, CDN) → nginx → app processes → local DB/Redis.
  • Backups off-site, snapshots on, restores tested.
  • Typical cost: tens of dollars per month.

03Stage 2: app server + managed database + object storage + CDN

When the data becomes valuable or the database needs more memory, move state off the app server. A managed database brings automated backups, point-in-time recovery and an optional standby. User uploads go to S3-compatible object storage instead of the local disk, served through a CDN. Sessions and cache go to Redis, managed or on a private server. The app server now holds no unique data, which is the key change: you can rebuild or replace it at any time, resize it with little risk, and later add a second one. Configuration and secrets come from environment variables, and deploys build the same artifact for staging and production. This stage is the sweet spot for many profitable products and can carry a surprising amount of traffic with good caching and indexes.

  • App server is stateless: code + config only.
  • Database: managed, private network access only, PITR on.
  • Files: object storage + CDN; no uploads on server disk.

04Stage 3: load balancer, several app servers, replicas and queues

When one app server is no longer enough, or the business needs to survive a server failure, put a load balancer in front of two or more identical app servers, ideally in different availability zones. The load balancer health-checks them and stops sending traffic to a broken one. Deploys roll through servers one at a time. The database gets read replicas for heavy read traffic and reports, and a standby for failover. Slow work (emails, PDFs, image processing, webhooks, imports) moves to background queues with dedicated worker servers, so web requests stay fast and spikes are absorbed. Watch for hidden state: file uploads, sessions, scheduled jobs (run the scheduler on one node only or use a lock), and in-memory caches must all be shared or coordinated. Replication lag also means a user may not instantly read what they just wrote from a replica.

05Stage 4: containers and Kubernetes

Containers package each service with its dependencies, so the same image runs on a laptop, in CI and in production. Docker Compose on one or a few servers covers a lot of ground. Kubernetes (or a managed alternative such as ECS, Cloud Run or a managed Kubernetes service) adds scheduling across many machines, self-healing, rolling updates, autoscaling and service discovery. It pays off when you run many services, several teams deploy independently, or traffic varies strongly. The price is real: a platform to learn and operate, more YAML, more ways to misconfigure networking and security, and usually a higher bill. A managed control plane removes some pain but not the complexity of the workloads. Many successful products never need it; adopt it when the coordination cost of not having it is clearly higher.

06Environments, secrets, DR, monitoring and cost

Every stage needs at least a staging environment that mirrors production in versions and configuration, with its own database and credentials, so changes are tested before customers see them. Secrets are injected per environment from env files or a secrets manager, never shared between staging and production, never in Git. Disaster recovery means written, rehearsed steps: where the backups are, how to rebuild a server, how to fail over the database, who decides. Monitoring has three parts: metrics (CPU, memory, disk, latency, error rates, queue length, for example with Prometheus and Grafana), logs collected centrally (Loki or a hosted service) and alerts that wake someone only for real user impact, plus external uptime checks. Review cost monthly: unused servers, oversized instances, old snapshots and egress fees add up quietly.

  • dev → staging → production, same build artifact promoted forward.
  • Alert on symptoms (errors, latency, downtime), investigate with metrics and logs.
  • Tag resources by project and environment to see where money goes.

07Migrating or merging servers without losing data

Moving sites to a new server, or merging several servers into fewer, is mostly careful bookkeeping. Inventory first: every domain, app, database, cron job, worker, certificate, mailbox, environment file and upload folder on the old server. Build the new server from your baseline, then copy code, files and databases, and verify: run the apps against a hosts-file override, compare row counts and file counts, check logs. Lower the DNS TTL a day before. At cutover, briefly put the old app in maintenance mode, run a final incremental file sync and a fresh database dump, import it, then switch DNS (instant with Cloudflare proxy). Keep the old server running, untouched, until traffic, data and backups on the new one are verified for days; only then decommission it, after a final backup.

bash
# 1. Initial copy of code and uploads (preserves owners, permissions, links)
rsync -aHAX --info=progress2 [email protected]:/var/www/shop/ /var/www/shop/
 
# 2. At cutover: maintenance mode on the old server, then final delta sync
ssh [email protected] 'cd /var/www/shop && php artisan down'
rsync -aHAX --delete [email protected]:/var/www/shop/storage/ /var/www/shop/storage/
 
# 3. Fresh database copy streamed directly to the new server
ssh [email protected] 'mysqldump --single-transaction --routines --triggers shop_db' | mysql shop_db
 
# 4. Verify before switching DNS
mysql -e "SELECT COUNT(*) FROM shop_db.orders;"
ssh [email protected] "mysql -e 'SELECT COUNT(*) FROM shop_db.orders;'"
curl -sI --resolve example.com:443:127.0.0.1 https://example.com | head -1

Use it when

  • Stage 1 for MVPs, small businesses and internal tools with modest traffic and budget.
  • Stage 2 once data is valuable or uploads and database memory outgrow one machine.
  • Stage 3 when you need to survive a server failure or one app server is no longer enough.
  • Stage 4 when many services and teams deploy independently and the platform cost is justified.

Skip it when

  • Do not start with Kubernetes or microservices for a product that has not found its users yet.
  • Do not add servers to fix a slow app before checking queries, indexes and caching.
  • Do not migrate servers without an inventory and a rollback path (the old server kept alive).

Alternatives

  • PaaS (Laravel Cloud, Vercel, Render, Fly.io and similar)

    Most of the infrastructure is handled for you; higher unit cost and less control.

  • Serverless functions

    Scale to zero and per-request pricing; cold starts, limits and vendor-specific patterns.

  • Managed containers (ECS, Cloud Run)

    Container orchestration without running Kubernetes yourself; a common middle step.

  • Hosting panels

    Fine for many small sites and mailboxes; limited for custom, scaled applications.

Best practices

  1. Choose the simplest stage that meets your availability, RPO/RTO and budget, and write those targets down.
  2. Make app servers stateless early: uploads in object storage, sessions and cache in Redis.
  3. Keep staging close to production and promote the same build artifact.
  4. Separate secrets per environment and keep them out of Git.
  5. Off-site backups with scheduled restore drills and a written DR runbook.
  6. Alert on user-facing symptoms, and keep metrics and central logs for diagnosis.
  7. Review the bill monthly and remove what nobody uses.

Common mistakes

  • Over-engineering: a cluster, service mesh and five environments for an app with a hundred users.
  • Under-engineering: one server with no off-site backups and no monitoring carrying a paying business.
  • Scaling app servers horizontally while uploads and sessions still live on each server's disk.
  • Switching DNS during a migration without lowering TTL, final sync or verification, and deleting the old server too soon.
  • Sharing one database or one set of credentials between staging and production.

Deep dive

08Availability maths: nines, dependencies and error budgets

An availability target turns into a concrete allowance of downtime. Over a 30-day month, 99.5 percent allows about 3.6 hours, 99.9 percent about 43 minutes and 99.99 percent about 4.3 minutes, which no human on-call process can meet without automatic failover. Dependencies in series multiply: if the app needs the server, the database and a payment API, each at 99.9 percent, the whole path is roughly 99.7 percent. Redundancy in parallel raises it: two independent app servers that each fail 0.1 percent of the time are both down only about 0.0001 percent, if their failures are truly independent, which shared databases, regions or deploys often break. The gap between your SLO and 100 percent is the error budget. When it is healthy, ship features faster; when it is spent, slow down and invest in reliability. That turns reliability debates into a number both business and engineers agree on.

09Deploy strategies and expand-contract migrations

A rolling deploy replaces instances one at a time behind the load balancer: cheap, but for a while old and new versions serve traffic together. Blue-green runs a full second environment and switches traffic at once, with an instant rollback by switching back, at double the cost during the release. Canary sends a small share of traffic, say 5 percent, to the new version and widens it only if error rates and latency stay healthy. All three require that two code versions can run against the same database. The expand-contract pattern makes that possible: first expand (add the new column or table, nullable, without removing anything), deploy code that writes both and reads the new one, backfill in batches, and only in a later release contract by dropping the old column. Never rename or drop a column in the same release that stops using it. This discipline, not the deploy tool, is what makes zero-downtime releases real.

  • Release 1: add nullable column, deploy code that writes old and new.
  • Backfill in small batches, off-peak, with progress logged.
  • Release 2: read only the new column. Release 3: drop the old one.

10Alerting on what users feel: SLOs and burn rates

Alerts on CPU or memory produce noise: a server at 90 percent CPU that serves every request fast is fine. Page humans on symptoms instead, using the RED method for services (rate, errors, duration) and USE for resources (utilisation, saturation, errors) when diagnosing. The most robust pattern is the burn-rate alert from the Google SRE workbook: alert when the error budget is being consumed much faster than the SLO allows, checked over a long and a short window so it fires quickly for real incidents and resolves quickly after. A burn rate of 14.4 sustained for an hour consumes 2 percent of a 30-day budget, worth waking someone for. Slower burns become tickets, not pages. Keep Prometheus label cardinality under control: user IDs or full URLs as labels create millions of series and can take the monitoring system down exactly when you need it.

yaml
groups:
  - name: slo
    rules:
      - alert: ErrorBudgetFastBurn
        expr: |
          (sum(rate(http_requests_total{status=~"5.."}[1h]))
            / sum(rate(http_requests_total[1h]))) > (14.4 * 0.001)
          and
          (sum(rate(http_requests_total{status=~"5.."}[5m]))
            / sum(rate(http_requests_total[5m]))) > (14.4 * 0.001)
        for: 2m
        labels:
          severity: page
        annotations:
          summary: "99.9% SLO error budget burning 14x too fast"

11Capacity planning with Little's law

Little's law says the average number of requests in a system equals arrival rate times time spent in it. At 200 requests per second and 250 ms average response time, about 50 requests are in flight at any moment, so you need at least 50 PHP-FPM workers or equivalent concurrency, plus headroom. If latency doubles because the database slows down, the same traffic needs 100 workers, which is why slow dependencies exhaust worker pools so suddenly. Plan for peaks, not averages: many products see three to ten times average traffic at peak hours or during campaigns. Keep steady-state utilisation around 50 to 70 percent so a failed node or a spike does not tip the rest over. Load-test before launches with a tool such as k6, increasing load until latency bends, and record where it bends: that number is your real capacity. Cost follows the same logic: right-size by measured peaks, and watch egress and storage, which grow silently.

Real-world scenarios

01

A campaign will send ten times the usual traffic

Problem

Marketing announces a TV spot on Friday evening. The product runs on one app server with a managed database, and nobody knows how much load it can take.

How to solve it

Measure first: load-test staging with production-like data using k6, ramping until latency rises, and note which resource saturates. Then reduce work per request: cache the landing pages and public API responses at the CDN, add a short microcache in nginx, move emails and webhooks to queues. Scale the bottleneck temporarily, such as a larger app server or database instance, booked a day before. Freeze deploys on campaign day, prepare a lightweight static fallback page, and put dashboards for errors, latency, queue length and database connections on one screen. Afterwards, compare actual peak with the test to calibrate future planning.

02

The single production server dies at 3 a.m.

Problem

The provider reports a hardware failure. The VPS will not boot, the business runs on it, and snapshots exist but the newest is from last night.

How to solve it

Follow the runbook instead of improvising. Decide quickly whether to wait for the provider or rebuild; a known recovery estimate usually favours rebuilding. Create a server from the latest snapshot or from the baseline scripts, restore the newest off-site database backup and replay logs if point-in-time recovery is configured, and sync uploads from object storage. Test through a hosts-file override, then point DNS or the floating IP at the new machine. Post a status update early. Afterwards, write a blameless review: actual RTO and RPO versus targets, and whether the next step is a managed database or a standby server.

03

Consolidating servers while moving email away

Problem

Several old servers are being merged into fewer, and mailboxes hosted on one of them must move to a hosted email provider without losing messages.

How to solve it

Treat mail as its own migration. Create mailboxes at the new provider, lower the TTL on MX and related records a day ahead, then copy existing mail with an IMAP sync tool such as imapsync. At cutover, switch MX records, publish the provider's SPF include and DKIM keys, and keep DMARC in monitoring mode (p=none) until reports show everything aligns. Run a final incremental IMAP sync after MX propagation to catch late messages. Applications sending mail through the old server must be switched to the provider's SMTP or an email API. Keep the old mail service read-only for a few weeks before decommissioning.

Cheat sheet

Check current DNS answer and TTL
dig +noall +answer example.com A
Test the new server before switching DNS
curl -sI --resolve example.com:443:203.0.113.20 https://example.com
Rehearse a sync without copying
rsync -aHAXn --delete --itemize-changes src/ dest/
Render the final Compose config
docker compose -f compose.yml -f compose.prod.yml config
Quick load test
k6 run --vus 50 --duration 2m load-test.js
Downtime allowed per 30 days
99.5% = 3.6 h, 99.9% = 43 min, 99.95% = 22 min, 99.99% = 4.3 min
Workers needed (Little's law)
concurrency = requests per second x average latency in seconds
Laravel maintenance mode with a bypass secret
php artisan down --secret="your-random-token"
Verify SPF and DMARC records
dig +short TXT example.com && dig +short TXT _dmarc.example.com

In my real projects

  • I run four production Ubuntu servers on DigitalOcean: two hosting-panel servers with many Laravel and Next.js sites, one dedicated server for a multi-tenant POS SaaS, and one for an e-commerce platform.
  • The SaaS server runs Docker Compose with separate dev, prod, TLS and observability overrides, nginx, PostgreSQL, Redis, BullMQ workers, Prometheus, Grafana and Loki, plus runbooks and backup scripts.
  • My approach to consolidation is four servers into two (the SaaS alone, all other sites together) without a panel, with mail moved to a hosted provider, DNS on Cloudflare, off-server backups and uploads moving to S3-compatible storage.
  • For merging I follow inventory, copy, verify, switch DNS, and keep the old server until everything is verified.

Questions & answers

Q1What infrastructure would you recommend for a new startup product?

Usually one well-configured server or a simple PaaS, not a cluster. A single VPS with nginx, the app, a local or small managed database, Redis, and Cloudflare in front handles far more traffic than most new products get. What I insist on is the operational basics from day one: hardening, staging, scripted deploys, off-site backups with a tested restore, uptime and error monitoring. I design the app to be stateless, uploads in object storage, sessions in Redis, configuration from environment variables, so moving to a managed database and several app servers later is a configuration change, not a rewrite. Spend the money on the product until real load or availability needs justify more.

Q2How do you scale from one server to several?

First I remove state from the app server: the database moves to its own private or managed instance, uploads go to object storage behind a CDN, sessions, cache and locks go to Redis, and scheduled jobs run once with a lock. Then I put a load balancer with health checks in front of two or more identical app servers, built from the same image or script, and roll deploys through them one at a time. Slow work moves to queue workers that scale separately. If reads dominate, I add read replicas and route reporting there. Before all of this I profile, because a missing index often buys more than a second server.

Q3When does Kubernetes make sense?

When the coordination problem it solves is bigger than the platform it requires. That typically means many services, several teams deploying independently, variable traffic that benefits from autoscaling, or a need for consistent self-healing and rolling updates across many machines. For one or a few applications, Docker Compose on a couple of servers, or a managed container service like ECS or Cloud Run, gives most of the benefit with far less operational load. Kubernetes needs people who understand networking, resource limits, security policies and upgrades, even with a managed control plane. I would rather run a simple setup well than a complex one badly.

Q4How do you migrate a production site to a new server without losing data?

Inventory everything on the old server: domains, apps, databases, cron jobs, workers, certificates, environment files, uploads and mail. Build the new server from the baseline, copy code and files with rsync, restore a database copy, and test the site through a hosts-file override. Lower DNS TTL a day ahead. At cutover, enable maintenance mode, run a final incremental rsync and a fresh database dump and import, compare row and file counts, then switch DNS. Watch logs and traffic on the new server, keep the old one untouched for several days as a rollback, take a final backup of it, and only then decommission.

Q5What is the difference between staging and production, and how close should they be?

Production serves real users and data. Staging is a rehearsal environment where you test releases, migrations and configuration before they reach production. It should match production in software versions, configuration structure, build process and infrastructure shape, so problems appear there first, but with its own database, credentials and integrations in sandbox mode, and usually smaller servers. Real personal data should not be copied to staging without anonymisation. The same build artifact should move from staging to production, so you test exactly what you ship. The closer they are, the fewer surprises, though a staging that costs as much as production is rarely justified for small teams.

Q6What should a minimal monitoring and alerting setup include?

Four things. External uptime checks on key URLs and APIs, alerting by chat or phone. Host metrics: disk space, memory, CPU load, and database connections, with alerts before disks fill. Application signals: error tracking with stack traces, request latency and error rates, queue length and failed jobs. And certificate expiry plus backup job success, two failures that are silent until they hurt. Logs should be collected centrally with enough retention to investigate incidents. Alerts must be actionable and few; if people ignore them, they are noise. A Prometheus, Grafana and Loki stack covers this self-hosted, while hosted tools trade money for less maintenance.

Senior-level questions

S1When is a multi-region setup worth it?

Rarely for small and medium products. A single region with several availability zones already survives a server or data centre failure; a full regional outage is uncommon. Multi-region adds hard problems: replicating the database across hundreds of milliseconds of latency, choosing between asynchronous replication with possible data loss and synchronous replication with slow writes, routing users, and testing failover that is rarely exercised. Costs roughly double. It is justified when contracts require regional resilience, data residency laws demand local storage, or users across continents need low latency for writes. A cheaper middle ground is warm standby: backups and infrastructure code ready in a second region, with a tested recovery time of hours, not seconds.

S2Design disaster recovery for a SaaS with an RPO of 5 minutes and an RTO of 1 hour.

A five-minute RPO rules out nightly dumps: I need continuous WAL or binlog archiving to storage in another account or region, or a managed database with point-in-time recovery, plus uploads in replicated object storage. A one-hour RTO means rebuilding must be automated: infrastructure as code or scripted baselines, container images in a registry, secrets restorable from a manager, and DNS with a low TTL or a load balancer I can repoint. A warm standby replica makes the database part minutes instead of a restore. Then prove it: a quarterly drill that restores into a clean environment from off-site copies only, timed against both targets, with a written runbook and named decision makers.

S3Self-hosted observability or a hosted service?

Self-hosting Prometheus, Grafana and Loki costs little in licences and keeps data under your control, which suits a single dedicated environment or strict data rules. But it is another production system: storage grows, upgrades break dashboards, and if it runs on the server it monitors, it dies in the same incident. Hosted tools charge by hosts, series or log volume and can get expensive with high-cardinality metrics or verbose logs, but they are available when your infrastructure is not and need no maintenance. My usual answer: external uptime checks and error tracking hosted, always; metrics and logs self-hosted on a separate small server when budget matters, hosted once the team's time costs more than the bill.

S4How do you avoid lock-in when choosing managed services?

Lock-in is a cost to manage, not something to avoid at any price; managed services exist because they save real time. I prefer services built on open interfaces: PostgreSQL or MySQL rather than proprietary databases, S3-compatible object storage, Redis-compatible caches, containers and standard protocols like SMTP and OpenTelemetry. Application code talks to these through framework abstractions such as Laravel's filesystem, queue and mail drivers, so switching providers is configuration. Infrastructure as code documents everything that would need recreating. I accept deeper lock-in only where a service gives a clear advantage, and I keep data exportable with regular backups outside the vendor, because the hardest thing to move is always data, not compute.

Glossary

SLO
Service level objective: an internal reliability target, such as 99.9 percent of requests succeeding over 30 days.
SLA
Service level agreement: a contractual promise to customers, usually looser than the SLO, with penalties if missed.
Error budget
The amount of unreliability an SLO allows; spending it guides whether to ship features or fix stability.
Single point of failure
One component whose failure takes the whole service down, such as a lone server or database.
Stateless server
An app server holding no unique data, so it can be replaced or duplicated at any time.
Blue-green deploy
Running two full environments and switching traffic from the old to the new one at once.
Canary release
Sending a small share of traffic to a new version first and expanding only if it behaves well.
Expand-contract
Changing a database schema in backward-compatible steps so old and new code can run at the same time.
Burn rate
How fast the error budget is being consumed relative to the rate that would exactly exhaust it.
Egress
Data transferred out of a cloud provider's network, often billed per gigabyte and easy to overlook.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.