Not every Laravel app needs its own platform. Small products, client portals, and internal tools often get a few hundred visits a day, and paying for a managed environment for each one adds up quickly. At VividSol, eight Laravel sites share one small DigitalOcean droplet: Ubuntu 24.04, nginx, PHP 8.4-FPM, MySQL 8, and about 1 GB of RAM. This site is one of them, and it is the same push-to-deploy setup we use when we host Laravel applications for clients.
It works because of a few strict rules, not because the server is clever. Here is the setup.
Every site has the same shape
Each site gets exactly the same layout, so nothing has to be remembered per site:
- Its own directory under
/var/www, cloned from a private GitHub repository with a read-only deploy key. - Its own database and database user, with random names. The credentials exist only in that site's
.envon the server. - Its own nginx virtual host and a Let's Encrypt certificate from certbot.
- Its own queue worker under Supervisor and its own scheduler entry in cron.
- User uploads in object storage where a site has them, so the server's disk holds code, not customer files.
Sameness is the main feature. When something breaks at night, I do not want to discover that this one site was set up differently.
Rule 1: never build on the server
With 1 GB of RAM shared by eight sites, a Vite build on the server would starve or crash its neighbors. So npm never runs there. Assets are built on my machine, and public/build is committed to git, which is unusual but deliberate. The deploy script checks for the build manifest and aborts before touching the database if it is missing:
if [ ! -f public/build/manifest.json ]; then
echo "!!! public/build/manifest.json is missing — aborting deploy."
exit 1
fi
Rule 2: one deploy script, identical everywhere
Every site runs the same deploy.sh, byte for byte. It finds its own directory, so the same file works anywhere. In order, it:
- Refuses to run on a dirty working tree. A modified file on the server means someone edited production by hand, and a pull could clobber it or conflict.
- Records the current commit as the rollback point.
- Arms a rollback. If any later step fails, a trap resets the code to that commit, reinstalls dependencies, rebuilds the caches, restarts the workers, and brings the site back up.
- Puts the site in maintenance mode and pulls.
- Runs
composer installonly whencomposer.lockorcomposer.jsonchanged, because it is the slowest step and usually unnecessary. - Migrates, caches config, routes, and views, and restarts the queue workers, so no worker keeps running old code.
- Brings the site back up.
The core of the rollback is short:
PREVIOUS_COMMIT="$(git rev-parse HEAD)"
rollback() {
trap - ERR
git reset --hard "$PREVIOUS_COMMIT"
composer install --no-dev --optimize-autoloader
php artisan config:cache
php artisan queue:restart
php artisan up
exit 1
}
trap rollback ERR
A deliberate gap: migrations are not rolled back
When a deploy fails, the code goes back but the database does not. Reverting migrations automatically can destroy data that was written in the seconds after they ran, and that is a worse outcome than a failed deploy. The rule that makes this safe is writing migrations that the previous version of the code can live with: add columns before using them, and remove them only in a later release.
Content ships as data migrations
Deploys run migrations but never seeders. So when content has to reach production with the code, it ships as a data migration that calls a seeder and inserts only what is missing. This Insights section gets its articles the same way.
Rule 3: push to deploy, one at a time
A push to main triggers a GitHub Actions workflow that connects over SSH and runs the deploy script. On this site, much of what gets pushed is written with an AI coding agent, which is why people own commits and deploys. A concurrency group makes sure two deploys never overlap:
concurrency:
group: production-deploy
cancel-in-progress: false
Queued, not cancelled. A half-finished deploy cancelled by a newer one is exactly the state you do not want to debug.
Rule 4: least privilege by default
Application commands run as a deploy user that owns the code and has no passwordless sudo. Root is used only for what needs it: nginx, Supervisor, certbot, and database administration. Each repository has its own read-only deploy key, so one leaked key exposes one repository, not all of them. Secrets live only in each site's .env and are never printed in a terminal or a log.
Rule 5: protect the neighbors
On a shared server, every change is a change to eight sites:
- Every nginx reload is gated on
nginx -t. A typo in one virtual host should never take down the other seven. - After any change to nginx, Supervisor, or cron, every site is checked to confirm it still answers.
- Never
chmod -Rfiles that git tracks. It dirties the working tree, and the next deploy will refuse to run.
The runbook is code
The steps for adding a site, deploying, and verifying are scripts with named phases: provision, finish, deploy, verify. Each phase can be re-run safely after a failure, and each skips what already exists. A runbook kept as a document drifts from reality within weeks. A runbook kept as scripts is either correct or visibly broken.
The honest limits
One droplet is a single point of failure. If it goes down, eight sites go down. It suits products with modest traffic and owners who accept that trade, with database backups taken off the server. A site that needs real uptime guarantees, heavy queues, or more than a fair share of 1 GB of RAM should move to its own server or a managed platform. The shared setup makes that move easy because every site is already built the same way.
My take
Cheap infrastructure is not the risk. Inconsistent infrastructure is. One small server running eight apps is reliable when every site is shaped the same, every deploy runs the same script, nothing is built or edited on the server, and every change checks the neighbors. The discipline is what you are actually running. If you would rather not run it yourself, this is part of what I help with in consulting.