Deploying PHP: Shared Hosting & VPS
Code on your laptop helps nobody until it runs on a server. Deployment is also where many beginners leak passwords, break a working site or expose their whole project folder to the internet. Most Indian small businesses use shared hosting (a control panel such as cPanel or hPanel, many sites on one server), while growing apps move to a VPS (a virtual private server where you control everything). You need to be comfortable with both.
In this lesson you will prepare an app for production, deploy it to shared hosting, set up an Ubuntu VPS with Nginx and PHP-FPM, enable HTTPS, and use a release-folder script that switches versions with no downtime and rolls back in seconds. The commands assume the project layout used throughout this course: public/ for web files, src/, config/, storage/, vendor/.
Before any deployment
Work through this checklist every time; most "it works on my machine" problems come from skipping one line:
- Same PHP version and extensions as development. Compare
php -vandphp -mlocally and on the server (and remember the server's CLI and web PHP can differ, as Command-Line Scripts & Cron Jobs explains). - Dependencies installed with
composer install --no-dev --optimize-autoloader, never by uploading your laptop'svendor/with development tools inside. - Configuration and secrets in environment variables or an uncommitted config file outside the web root, as in Best Practices & Tooling.
- Errors hidden and logged:
display_errors=Off,log_errors=On(see PHP Security Essentials). - The web root is
public/, soconfig/,.env,vendor/andstorage/cannot be downloaded. - Database changes (new tables or columns) prepared as SQL migration files and applied before the new code needs them.
Shared hosting
On cPanel, hPanel and similar panels the steps are the same, only the menu names differ:
- Choose the PHP version for the domain ("Select PHP Version", "PHP Configuration") and enable the extensions you need, such as
pdo_mysql,mbstring,intlandzip. - Create the database and user in the MySQL section, give the user rights on that database only, and note the host name the panel shows.
- Upload the code. Best: the panel's Git feature or
git pullover SSH. Otherwise SFTP or a ZIP uploaded through the File Manager. Never use plain FTP, which sends your password unencrypted. - Install dependencies. Many hosts include Composer in their SSH terminal. If yours does not, run
composer install --no-dev -olocally and uploadvendor/too. - Point the domain at
public/. If the panel lets you set the document root of the domain, set it to.../shop/public. If the domain must usepublic_html, keep the app beside it and copy only the contents ofpublic/intopublic_html:
/home/priya/
shop/ app code: src, config, vendor, storage (not reachable from the web)
public_html/ contents of shop/public: index.php, css, js, imagesThen fix the one path in public_html/index.php:
<?php
declare(strict_types=1);
require __DIR__ . '/../shop/vendor/autoload.php';
require __DIR__ . '/../shop/src/bootstrap.php';- Route all requests through
index.phpwith an.htaccessfile in the web root (Apache and LiteSpeed, used by most shared hosts, read it):
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]- Turn on HTTPS with the panel's free SSL option (usually Let's Encrypt or AutoSSL), then redirect all HTTP traffic to HTTPS.
- Add cron jobs in the panel for background scripts, and check file permissions: folders
755, files644, and onlystorage/writable by PHP. Never use777.
A VPS with Nginx and PHP-FPM
On a VPS you install the software yourself. A common, fast combination is Nginx (the web server, which serves static files and passes PHP requests on) and PHP-FPM (FastCGI Process Manager, a pool of PHP worker processes). On Ubuntu 26.04 LTS:
sudo apt update && sudo apt upgrade
sudo apt install nginx php-fpm php-mysql php-mbstring php-xml php-curl php-intl php-zip unzip
php -v
ls /run/php/The php-* packages install the PHP version that your Ubuntu release provides; php -v shows which one, and ls /run/php/ shows the PHP-FPM socket name, such as php8.4-fpm.sock. Install MySQL with sudo apt install mysql-server and run sudo mysql_secure_installation, or use a managed database from your cloud provider.
Basic security first: create a normal user for deployments instead of working as root, log in with SSH keys, and allow only the ports you need:
sudo adduser deploy
sudo usermod -aG www-data deploy
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableThen tell Nginx about the site in /etc/nginx/sites-available/shop and link it into sites-enabled:
server {
listen 80;
server_name shop.example.in;
root /var/www/shop/current/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known) {
deny all;
}
}try_files serves real files (CSS, images) directly and sends everything else to index.php, the front controller from A Simple MVC Router. The last block blocks hidden files such as .env and .git. $realpath_root resolves the current symlink you will meet below, so PHP and OPcache see the real release folder. Test and apply with sudo nginx -t && sudo systemctl reload nginx, then add HTTPS:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d shop.example.inCertbot obtains a free Let's Encrypt certificate, edits the Nginx config to use it and sets up automatic renewal.
Zero-downtime releases
Copying new files over the live site means that for a few seconds some files are new and some are old, and visitors see errors. Professional deployments build each version in its own folder and switch a symlink called current in one step:
/var/www/shop/
current -> releases/20261102101500
releases/
20261101183000/
20261102101500/
shared/
.env
storage/#!/usr/bin/env bash
# deploy.sh: build a new release next to the live one, then switch in one step
set -euo pipefail
APP=/var/www/shop
REPO=git@github.com:priya/shop.git
RELEASE="$APP/releases/$(date +%Y%m%d%H%M%S)"
echo "Building $RELEASE"
git clone --depth 1 --quiet "$REPO" "$RELEASE"
cd "$RELEASE"
composer install --no-dev --optimize-autoloader --no-interaction --quiet
# Files that must survive between releases live in shared/
ln -s "$APP/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage"
ln -s "$APP/shared/storage" "$RELEASE/storage"
# The switch: point "current" at the new release in one step
ln -sfn "$RELEASE" "$APP/current"
echo "Live: $(readlink "$APP/current")"
sudo systemctl reload php8.4-fpm # clears OPcache; use your PHP version
# Keep the five newest releases for quick rollback
ls -1dt "$APP"/releases/*/ | tail -n +6 | xargs -r rm -rfset -euo pipefail stops the script at the first failed command, so a broken composer install never goes live. Uploads and logs stay in shared/storage across releases. Reloading PHP-FPM clears OPcache, which matters if you set opcache.validate_timestamps=0 as in Performance: OPcache, Caching & Profiling. Rollback is one command: point current back at the previous release folder and reload PHP-FPM. Tools such as Deployer (written in PHP) and CI services like GitHub Actions automate exactly these steps.
Check that it works
After every deployment, check the site automatically instead of hoping. A small health-check page tells you and your monitoring service that the app can reach what it needs:
<?php
declare(strict_types=1);
// public/health.php
header('Content-Type: application/json');
header('Cache-Control: no-store');
$checks = [];
try {
$pdo = new PDO(getenv('DB_DSN') ?: '', getenv('DB_USER') ?: null, getenv('DB_PASS') ?: null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 3,
]);
$pdo->query('SELECT 1');
$checks['database'] = 'ok';
} catch (PDOException) {
$checks['database'] = 'failed'; // never print the exception: it may contain the password
}
$storage = dirname(__DIR__) . '/storage';
$checks['storage_writable'] = is_writable($storage) ? 'ok' : 'failed';
$healthy = !in_array('failed', $checks, true);
http_response_code($healthy ? 200 : 503);
echo json_encode(['status' => $healthy ? 'ok' : 'error', 'checks' => $checks]), "\n";curl -s https://shop.example.in/health.php{"status":"ok","checks":{"database":"ok","storage_writable":"ok"}}When the database is unreachable, the same page answers with status 503 and {"status":"error","checks":{"database":"failed","storage_writable":"ok"}}, and reveals nothing secret. Free uptime monitors can call it every few minutes and alert you. After deploying, also open the main pages, submit one form, and watch the error log for a few minutes (tail -f storage/logs/app-*.log).
Common mistakes
- Document root at the project folder instead of
public/, exposingconfig/and.env. - Different PHP versions locally and on the server, discovered only when a page crashes.
chmod 777to "fix" permission errors. Give the web server user write access tostorage/only.- Copying files over the live site instead of switching releases, causing minutes of half-updated code.
- No HTTPS, so logins and session cookies travel in plain text.
Key points
- Check PHP version, extensions,
--no-devdependencies, secrets anddisplay_errorsbefore every deployment. - Point the web root at
public/; on shared hosting keep the app besidepublic_htmlif needed. - Route requests to
index.phpwith.htaccesson Apache ortry_fileson Nginx. - On a VPS: Nginx plus PHP-FPM, a non-root user, a firewall and Let's Encrypt via Certbot.
- Build each release in its own folder, share
.envandstorage/, switch acurrentsymlink and reload PHP-FPM. - Verify every deployment with a health check, a quick manual test and the logs.
Exercise
Write a rollback.sh script for the release layout above. It should find the release that current points to, find the release just before it (by folder name order), stop with an error message if there is none, switch current to it with ln -sfn, reload PHP-FPM and print which release is now live.
Show solution
#!/usr/bin/env bash
set -euo pipefail
APP=/var/www/shop
live=$(basename "$(readlink "$APP/current")")
previous=$(ls -1 "$APP/releases" | sort | grep -B1 -x "$live" | head -n 1)
if [ -z "$previous" ] || [ "$previous" = "$live" ]; then
echo "No older release to roll back to." >&2
exit 1
fi
ln -sfn "$APP/releases/$previous" "$APP/current"
sudo systemctl reload php8.4-fpm
echo "Rolled back from $live to $previous"Rolled back from 20261102101500 to 20261101183000
Release folder names are timestamps, so sorting them by name also sorts them by time. grep -B1 -x prints the line before the live release; if the live release is the oldest, only its own name comes back and the script stops with exit code 1.
Quick Quiz
1. Why should the web server's document root be the public/ folder?
Only files meant for visitors should be reachable over HTTP.
2. What does PHP-FPM do in an Nginx setup?
Nginx handles HTTP and static files; PHP-FPM executes the PHP code.
3. Why switch a current symlink instead of copying files over the live site?
Each release is complete before it goes live, and the previous one is kept for rollback.
4. A health-check page fails to connect to the database. What should it show?
Monitoring needs a clear status; attackers must not see connection details.
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
