How to Deploy a PHP App to a VPS with Nginx, HTTPS and Backups
Install Nginx and PHP-FPM on a current Ubuntu LTS server, point the Nginx root at your app's public folder only, pass PHP requests to the FPM socket and add a free certificate with Certbot. Then limit the firewall to SSH, 80 and 443, tighten file permissions and schedule backups that are copied off the server.
Prepare the server
A small PHP app with SQLite does not need much: an entry-level plan from any reputable provider is a sensible start, and you can resize later. Pick a region close to most of your users, and enable the provider's own snapshot or backup feature if it has one, as a second safety net alongside your database backups.
Start with a fresh current Ubuntu LTS server and a domain whose A record (and AAAA record, if you use IPv6) points at it. DNS changes can take a while to reach everyone, so set them up first. Then:
- Create a non-root user with
sudoand add your SSH public key. - In
/etc/ssh/sshd_config, disable password authentication and root password login, then reload SSH. Keep your current session open until a new login works. - Apply updates and enable automatic security updates (
unattended-upgrades). - Turn on the firewall, allowing SSH before enabling it so you do not lock yourself out:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Install Nginx and PHP-FPM
sudo apt update
sudo apt install -y nginx sqlite3 composer \
php-fpm php-cli php-sqlite3 php-curl php-mbstring php-xml php-zip
ls /run/php/ # note the socket name, e.g. php8.3-fpm.sock
The unversioned php-* packages install the release your Ubuntu version ships with; the socket name includes that version. Add other extensions your app needs, such as php-mysql or php-gd. PHP-FPM runs as www-data by default on Ubuntu.
Write a safe Nginx server block
The most important line is root. Point it at the folder containing only public files, never the project root, or your .env, source and database may be downloadable.
server {
listen 80;
server_name example.com;
root /var/www/app/public;
index index.php;
server_tokens off;
client_max_body_size 25m;
location ^~ /.well-known/ { allow all; try_files $uri =404; } # Certbot
location ~ /\. { deny all; }
location ~* \.(env|sqlite|db|log|sql|bak|ini|sh)$ { deny all; }
location / {
try_files $uri $uri/ /index.php?$query_string; # front controller
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock; # match your socket
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_AUTHORIZATION $http_authorization; # bearer tokens
}
}
Save it in /etc/nginx/sites-available/, symlink it into sites-enabled/, then run sudo nginx -t before sudo systemctl reload nginx. The deny rules are defence in depth; the correct root is what really protects private files.
Two optional improvements: give static assets a long cache lifetime (safe when their URLs change on each release, for example with a version query string), and check that gzip_types in nginx.conf includes CSS and JavaScript, since Ubuntu's default configuration compresses only HTML until you enable it. If your app sits behind a CDN or proxy, configure Nginx's real-IP module so logs and rate limits see the visitor's address rather than the proxy's.
Add HTTPS with Let's Encrypt
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
sudo certbot --nginx -d example.com
sudo certbot renew --dry-run
Certbot obtains a free certificate, adds the HTTPS server block and an HTTP-to-HTTPS redirect, and installs a timer that renews certificates automatically. The dry run proves renewal works. Once HTTPS is stable, add HSTS to the HTTPS block and set your app's base URL to https://. Payment webhooks and sign-in links should never travel over plain HTTP.
Deploy the code and set permissions
sudo mkdir -p /var/www/app && sudo chown deploy:www-data /var/www/app
git clone git@github.com:you/app.git /var/www/app
cd /var/www/app && composer install --no-dev --optimize-autoloader
sudo find /var/www/app -type d -exec chmod 750 {} \;
sudo find /var/www/app -type f -exec chmod 640 {} \;
sudo chmod 2770 /var/www/app/storage # PHP writes here; setgid keeps the group
sudo chmod 640 /var/www/app/.env
Create .env on the server rather than copying your local one, with production values: debug off, the HTTPS URL, live payment keys and fresh secrets. The pattern for permissions is: your deploy user owns the files, www-data is the group, PHP can write only where it must, and nothing is world-readable. SQLite needs write access to the database's directory as well as the file, because it creates -wal and -shm files beside it. More on that in is SQLite good enough for production.
Shipping updates without surprises
A small app does not need a deployment pipeline, but it does need a repeatable routine:
- Run your tests locally and make sure the change is committed.
- Take a backup of the database immediately before deploying.
- On the server, pull the new code and install dependencies with
composer install --no-dev. - Apply database migrations, then check syntax on the main entry points with
php -l. - Reload PHP-FPM (
sudo systemctl reload php8.3-fpm, matching your version) so OPcache picks up the new files. - Click through sign-in and one paid flow, and watch the error log for a few minutes.
Know how you would roll back before you need to: check out the previous tag and restore the pre-deploy backup if a migration went wrong. If your host offers server snapshots, take one before large upgrades too. Pushing to a Git host should not deploy anything on its own unless you have deliberately set that up and tested it.
Backups, cron and monitoring
# crontab -e (as the deploy user): hourly consistent SQLite backup
15 * * * * cd /var/www/app && sqlite3 storage/app.db ".backup 'backups/app-$(date +\%Y\%m\%d-\%H\%M).db'"
Note the escaped % signs, which cron otherwise treats as newlines. Then copy backups off the server with a tool such as rclone, restic or rsync, because a backup on the same disk dies with the disk. Prune old copies, and restore one into a scratch directory every so often to prove it works.
Add an external uptime check, keep an eye on disk space, and read your PHP and Nginx error logs after each deploy (/var/log/nginx/error.log and your app's own log directory). For a sense of when one small server stops being enough, see hosting a SaaS on a small VPS; for a pre-launch security pass, see taking an app to production safely.
Sources: Certbot instructions for Nginx · Nginx: configuring HTTPS servers · PHP: FastCGI Process Manager · SQLite: online backup API
How Simple Stack handles it
Simple Stack is built for exactly this setup. DEPLOY.md walks through it, ops/nginx-site.conf is a commented server block, and php scripts/doctor.php --prod checks the result. Apache hosts are covered by the bundled public/.htaccess.
- ops/nginx-site.conf: public/ root, clean URLs, private-path denies and Authorization forwarding
- doctor --prod checks HTTPS APP_URL, debug off, live Stripe keys and database location
- The database and its -wal/-shm files are clamped to 0660; doctor fails if it is world-readable
- scripts/backup_db.php writes timestamped SQLite backups at 0640 and prunes old ones
- Security headers, and HSTS over HTTPS, are sent from PHP, so they survive a web-server swap
- DEPLOY.md ends with a go-live checklist covering cron, TLS, firewall and permissions
Questions people ask
Skip the plumbing. Build the product.
Sign-in, Stripe, emails, an admin and a CMS — already built and tested in plain PHP. £49, once, for unlimited projects.