Railway, Render, and Fly.io are genuinely good at what they're built for: getting a container on the internet in an afternoon. The trouble starts six months later. Your queue worker is a separately billed "service." Your Postgres add-on costs more than a whole server would. Egress is metered. And PHP is clearly a second-class citizen on platforms designed around Node and Go, so things Laravel considers basic, like the scheduler or Horizon, feel bolted on.
If you want the full argument for leaving, we've already made it in why Laravel developers are leaving generic PaaS. This post is the other half: the how. It's a numbered runbook you can follow this weekend, with real commands for each platform, a database migration plan that keeps downtime to minutes, and a rollback path in case something goes sideways.
You're in good company making this move. In a Barclays CIO survey, 86% of CIOs said they plan to move at least some workloads back from public cloud, up from 43% in 2020. At the extreme end, 37signals reports saving roughly $2M per year, about $10M over five years by leaving AWS. Your numbers will be smaller. The direction is the same.
Step 1: Inventory Everything You're Actually Running
Before you touch a server, write down every moving part of your app. PaaS platforms hide a lot behind toggles, and the migrations that go wrong are almost always the ones where someone forgot a cron job or a webhook URL.
Your checklist:
- Web process: how many instances, how much RAM, which PHP version
- Queue workers: how many, which queues, Horizon or plain
queue:work - Scheduler: is
schedule:runwired up as a cron service or a dedicated process - Database: engine, version, size on disk
- Redis/Valkey: used for cache only, or also queues and sessions
- Object storage: platform volumes, Tigris, or external S3
- Environment variables: all of them, including the ones set years ago
- Inbound integrations: Stripe webhooks, OAuth callbacks, anything that points at your current URL
Exporting environment variables
This is the part people do by hand, badly, at 11pm. Use the CLI instead.
Railway:
npm i -g @railway/cli
railway login
railway link # select your project and environment
railway variables --kv > railway.env
The --kv flag gives you KEY=value lines you can diff against your local .env.
Render: the dashboard's Environment tab works for small apps, but the API is faster and complete:
curl -s -H "Authorization: Bearer $RENDER_API_KEY" \
"https://api.render.com/v1/services/$SERVICE_ID/env-vars?limit=100" \
| jq -r '.[].envVar | "\(.key)=\(.value)"' > render.env
Fly.io: fly secrets list only shows digests, not values, by design. The reliable way to get actual values is to dump them from a running machine:
fly ssh console -C "printenv" \
| grep -E '^(APP_|DB_|DATABASE_|REDIS_|CACHE_|QUEUE_|MAIL_|AWS_|SESSION_|BROADCAST_)' \
> fly.env
Now audit the export. Strip platform-injected variables (RAILWAY_*, RENDER_*, FLY_*, PORT) since they won't exist on your VPS. Note every variable that contains a platform hostname, like an internal postgres.railway.internal DB host. Those all change. Treat the exported file as radioactive: it contains production secrets, so keep it out of Git and delete it when you're done.
Step 2: Provision the VPS
One honest advantage PaaS platforms have is that they made you forget how cheap raw compute is. A 4 GB Hetzner CPX-class instance runs about €7.99/month, versus $24/month for the equivalent 4 GB DigitalOcean droplet. Either is a fraction of what most teams pay a PaaS for the same workload once you add up web, worker, database, and Redis line items.
Sizing guidance from apps we've migrated:
Your PaaS footprint
VPS to start with
One small web service, hobby DB
2 vCPU / 4 GB (Hetzner ~€7.99, DO $24)
Web + worker + 1-5 GB database
4 vCPU / 8 GB
Multiple workers, Horizon, busy DB
4-8 vCPU / 16 GB, or split app and DB onto two servers
Start with everything on one box: Nginx, PHP-FPM, MySQL or Postgres, Valkey, workers. It simplifies the migration enormously, and splitting the database out later is a much smaller project than this one. Egress pricing is also worth noticing: Hetzner charges around €1.19/TB against DigitalOcean's $10/TB and AWS's roughly $90/TB, which matters if your app serves files.
You have two ways to do this step. Manually: create the server in the provider console, then spend an evening with apt, PHP repositories, and config files. Or provision it through Deploynix, which connects to Hetzner, DigitalOcean, Vultr, Linode, AWS, or any custom VPS over SSH, and installs the full Laravel stack (Nginx, PHP-FPM, your database, Valkey, Supervisor) with production settings in one pass. We've written up why Hetzner plus Deploynix is our favorite price-to-performance combo if you want the detailed reasoning.
Either way, by the end of this step you should be able to SSH in and see PHP 8.3 or 8.4 (Laravel 13 requires PHP 8.3 or higher), your database engine, and Nginx answering on port 80.
Step 3: Recreate Your Services on the VPS
Everything the PaaS gave you as a toggle now becomes a config file. There are exactly four of them that matter. None is complicated.
Nginx + PHP-FPM for the web process
server {
listen 80;
server_name app.example.com;
root /var/www/app/current/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
Note the root points at current/public. Deploy into timestamped release directories and symlink current to the live one; that structure is what makes atomic deploys and instant rollbacks possible, as covered in the anatomy of a zero-downtime deploy.
Supervisor for queue workers
Your PaaS "worker service" becomes a Supervisor program:
[program:app-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=4
user=deploy
stopwaitsecs=3600
stdout_logfile=/var/log/supervisor/app-worker.log
Match numprocs to however many worker instances you ran on the PaaS. If you use Horizon, run one php artisan horizon program instead and let it manage the worker pool itself.
Cron for the scheduler
One line, and it's the line PaaS platforms made weirdly hard:
* * * * * cd /var/www/app/current && php artisan schedule:run >> /dev/null 2>&1
The database and Valkey
Install MySQL or Postgres locally, create the production database and a dedicated user, and point DB_HOST=127.0.0.1. Same for Valkey (REDIS_HOST=127.0.0.1). Localhost round trips are measured in microseconds; you'll likely notice queries getting faster compared to a managed add-on in another availability zone.
If you provisioned through Deploynix, all four of these are dashboard items rather than files: sites get the Nginx template automatically, queue workers and daemons are Supervisor entries you create in the UI, and cron jobs are a form field. The result on disk is the same.
Finally, deploy the app itself to the new server, with the adapted env file from Step 1, and run it against a copy of staging data or an empty database for now. You want the code proven on the new box before the real data arrives.
Step 4: Migrate the Database with Minimal Downtime
This is the step people fear, and for most apps the fear is oversized. For small-to-mid-size databases, a dump-and-restore inside a short maintenance window is simpler and safer than anything clever.
The maintenance window approach
First, do a full rehearsal restore days before cutover, with the app still live on the PaaS. This tells you exactly how long the real one will take.
Postgres (Railway, Render, and Fly all hand you a DATABASE_URL):
# Dump from the PaaS database
pg_dump "$DATABASE_URL" --format=custom --no-owner --no-acl -f app.dump
# Restore on the VPS
pg_restore --no-owner --no-acl --clean --if-exists \
-d "postgresql://app:secret@127.0.0.1/app_production" app.dump
MySQL:
mysqldump --single-transaction --quick --routines --triggers \
-h PAAS_HOST -u USER -p app_production | gzip > app.sql.gz
gunzip < app.sql.gz | mysql -h 127.0.0.1 -u app -p app_production
--single-transaction gives you a consistent snapshot without locking InnoDB tables, so even the rehearsal dump is safe to run against production.
On cutover day, the real sequence is:
# 1. On the PaaS app: stop accepting writes
php artisan down --secret="migrating-2026" --retry=60
# 2. Final dump and restore (you know the duration from the rehearsal)
# 3. Verify (below), then bring the VPS app up
php artisan up
The --secret flag matters: it gives you a bypass cookie so you can poke at the old app while users see the maintenance page.
Verifying the restore
Never trust a restore you haven't counted. On both databases, compare row counts table by table.
On Postgres, don't lean on pg_stat_user_tables.n_live_tup for this: it's a statistics estimate and can read zero or stale right after a pg_restore. Run real counts on your three or four hottest tables instead:
SELECT COUNT(*) FROM users;
SELECT COUNT(*) FROM orders;
SELECT COUNT(*) FROM subscriptions;
SELECT MAX(id), MAX(created_at) FROM orders;
Same idea on MySQL, where information_schema counts are also estimates:
SELECT COUNT(*) FROM users;
SELECT COUNT(*) FROM orders;
SELECT MAX(id), MAX(created_at) FROM orders;
Check MAX(created_at) on your hottest tables against the old database. If the newest row on the VPS matches the newest row on the PaaS, you captured everything up to the freeze.
When the window won't fit
If your database is large enough that dump-and-restore blows past an acceptable window, use replication instead: take a base dump early, configure the VPS database as a replica of the PaaS one (logical replication on Postgres, binlog replication on MySQL, assuming your PaaS tier exposes the access), let it catch up over hours or days, then cutover is just "stop writes, wait for replication lag to hit zero, promote." It's more setup for near-zero downtime. Most apps reading this post don't need it; a modest database restores in minutes.
Step 5: Move Object Storage and User Uploads
Where your files live decides how hard this step is.
Already on external S3, R2, or Spaces? You're done. The bucket doesn't care where your compute runs. Copy the same AWS_* credentials to the VPS and skip ahead.
On Fly.io with Tigris, it's S3-compatible, so rclone moves everything:
rclone sync tigris:my-app-bucket spaces:my-app-bucket --progress --checksum
On Railway or Render persistent volumes, the files live inside the platform's disk. On Render, render ssh (or the SSH details in the dashboard) lets you tar the disk and pull it over. On Railway, the least painful route is a one-off artisan command that pushes the local disk to S3-compatible storage using Laravel's own filesystem abstraction:
$files = Storage::disk('local')->allFiles('uploads');
foreach ($files as $path) {
Storage::disk('s3')->writeStream(
$path,
Storage::disk('local')->readStream($path)
);
}
Run it inside the deployed service: railway ssh gives you a shell in the running container, and you run php artisan uploads:push from there. Don't reach for railway run here; it executes the command on your local machine with Railway's env vars injected, so it would read your local disk and silently push the wrong files. Once the command finishes, point the VPS at the bucket.
Our opinionated recommendation: use the migration as the excuse to move uploads to S3-compatible object storage permanently, even though your VPS has plenty of disk. It keeps servers disposable, makes the next migration trivial, and backup tooling for buckets is better than backup tooling for random directories. Sync twice: once days ahead for the bulk, once during the maintenance window for stragglers. rclone sync is idempotent, so the second pass only moves what changed.
Step 6: Cutover
Cutover is a DNS change, but good cutovers are boring because of what happened in the 48 hours before.
48 hours out: lower your DNS TTL. Find the record pointing at the PaaS (often a CNAME to yourapp.up.railway.app or similar) and drop the TTL to 300 seconds. Resolvers worldwide need time to expire the old, long TTL before the short one takes effect, which is why you do this early.
24 hours out: smoke test the VPS as if it were live. Don't wait for DNS to test. Force your own machine to resolve the domain to the new server:
curl --resolve app.example.com:443:203.0.113.10 https://app.example.com/up
Laravel's built-in /up health route should return 200. Then walk the app the same way: log in, create a record, upload a file, trigger a job and watch it process, fire a test Stripe webhook. Every service from Step 3 gets exercised before a single user touches it.
Cutover hour, in order:
-
php artisan downon the PaaS app (Step 4). - Final incremental database sync and storage sync.
- Verify row counts.
- Flip DNS: change the record to an A record pointing at the VPS IP.
-
php artisan upon the VPS. - Watch it land.
For step 6, keep two terminals open on the VPS:
tail -f /var/www/app/current/storage/logs/laravel.log
tail -f /var/log/nginx/access.log
With a 300-second TTL, most traffic follows within minutes of the TTL expiring. Stragglers with stubborn resolvers can take a few hours, which is exactly why the old app shows a maintenance page instead of being deleted. Watch for a wall of 500s (env problem), 404s on uploads (storage path problem), or jobs stacking up in Redis (Supervisor problem). Silence plus normal access logs means you're done.
Update the external pointers from your Step 1 inventory now: webhook URLs at Stripe and friends, OAuth redirect URIs, uptime monitors.
Step 7: The Rollback Plan You Hopefully Won't Use
Write this down before cutover, not during an incident.
Rule one: don't delete the PaaS app. Scale it down if the platform bills by usage, but keep it deployable for at least a week. Rollback is then the cutover in reverse: point DNS back at the PaaS, bring its app out of maintenance mode.
Rule two: the database defines your point of no return. DNS is instantly reversible. Data is not. Once real users have written rows to the VPS database, rolling back to the PaaS means either losing those writes or dumping the VPS database back the other way. So define the decision window explicitly: "if the new stack isn't healthy within 60 minutes of DNS flip, we go back." Inside that window, writes are few and a reverse dump is quick. After you declare success, the PaaS database becomes a stale artifact and the VPS is the single source of truth.
Rule three: rehearse the health check, not just the migration. Decide in advance what "healthy" means: /up returning 200, queue depth near zero, error rate in the logs comparable to a normal day, checkout completing. Vague success criteria are how one-hour rollback windows become three-day debugging sessions with traffic split across two stacks.
Application-level rollbacks, as opposed to migration rollbacks, get much easier after this move. Release-based deploys mean reverting a bad deploy is just a symlink swap back to the previous release directory.
Step 8: Post-Migration Checklist
The PaaS did a few things silently that are now your job. Do these in the first 48 hours, not "eventually."
- SSL. Issue a Let's Encrypt certificate and confirm auto-renewal is actually scheduled. On a manual setup that's
certbot --nginx -d app.example.complus the systemd timer it installs. - Backups. Your managed database's automatic backups are gone. Set up nightly dumps shipped off-server to S3-compatible storage, and restore one to prove the pipeline works. A backup you've never restored is a hope, not a backup.
- Firewall. Default deny inbound, then allow only what you serve:
ufw allow OpenSSH && ufw allow 80,443/tcp && ufw enable. MySQL, Postgres, and Redis ports should never be reachable from the internet. - Monitoring. CPU, memory, disk, and an external uptime check. You lost the platform dashboard; replace it with something before you need it, not after.
- Unattended security updates.
apt install unattended-upgradesso kernel and OpenSSL patches don't wait for you to remember. - SSH hardening. Key-only auth,
PermitRootLogin no, and consider fail2ban. The full list lives in our production security checklist. - Log rotation. Confirm logrotate covers Nginx and Laravel logs, or the disk fills up in month three.
Also worth doing while everything is fresh: cancel the PaaS services once your rollback window closes, and calendar a reminder to verify the first certificate renewal actually happened.
Before and After: What the Move Does to Your Bill
A typical Laravel app on a generic PaaS pays for four to six separately metered line items. On a VPS, they collapse into one server plus a flat management fee.
Line item
Generic PaaS
Your own VPS
Web service
Metered per instance/RAM
Included in the server
Queue worker
A second billed service
A Supervisor program on the same box
Managed Postgres/MySQL
Separate add-on, priced by storage and compute
Included in the server
Managed Redis
Another add-on
Valkey on the same box
Bandwidth
Metered egress
Hetzner ~€1.19/TB; DO $10/TB, with generous included allowances
Server management
Included (it's the product)
Deploynix from $0 free tier, $12/mo Starter
The after column, concretely: a 4 GB Hetzner instance at ~€7.99/month plus Deploynix Starter at $12/month lands around $21/month, flat, with no usage metering. The same 4 GB of RAM on DigitalOcean is $24/month if you'd rather have US datacenters and a familiar console. We won't quote a "typical" PaaS bill because it varies wildly with usage, which is rather the point. Pull up your last invoice and put it next to that table. For most apps past the hobby stage, the VPS column wins by a multiple, not a percentage, and the gap widens every month your traffic grows.
What you give up is real too: no more git push and forget, and the OS is your responsibility now (that's what the Step 8 checklist and a management layer are for). What you gain is a stack where Laravel is the first-class citizen: the scheduler is a cron line, Horizon is a daemon, and nobody bills you extra for either.
Where This Leaves You
The whole migration, condensed: inventory and export everything, provision one adequately sized VPS, turn platform toggles into four config files, rehearse the database restore before you need it, move uploads to object storage, lower TTLs early, cut over behind a maintenance page, and keep the old stack alive until you've declared success against criteria you wrote down in advance.
None of the individual steps is hard. The failure mode is skipping the boring ones: the rehearsal restore, the row-count verification, the written rollback window. Do those and the actual cutover is fifteen quiet minutes of watching access logs.
If you'd rather compress Steps 2, 3, and 8 into an afternoon, Deploynix provisions the server, configures Nginx, PHP-FPM, your database, Supervisor workers, cron, SSL, and monitoring, and gives you zero-downtime deploys with one-click rollback from day one, on your own Hetzner, DigitalOcean, Vultr, Linode, AWS, or custom server. The free tier covers one server and three sites, which is exactly the shape of a freshly escaped PaaS app. Either way, manual or managed, the destination is the same: your app, on hardware you control, at a price that stops scaling against you.
Top comments (0)