EthicsComputerCourses
Join training +91 90532 10052

Deploying PHP: Shared Hosting & VPS

PHP · Lesson 62 of 63 Advanced 9 min read Updated

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 -v and php -m locally 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's vendor/ 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/, so config/, .env, vendor/ and storage/ 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:

  1. Choose the PHP version for the domain ("Select PHP Version", "PHP Configuration") and enable the extensions you need, such as pdo_mysql, mbstring, intl and zip.
  2. 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.
  3. Upload the code. Best: the panel's Git feature or git pull over SSH. Otherwise SFTP or a ZIP uploaded through the File Manager. Never use plain FTP, which sends your password unencrypted.
  4. Install dependencies. Many hosts include Composer in their SSH terminal. If yours does not, run composer install --no-dev -o locally and upload vendor/ too.
  5. 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 use public_html, keep the app beside it and copy only the contents of public/ into public_html:
Text
/home/priya/
  shop/            app code: src, config, vendor, storage (not reachable from the web)
  public_html/     contents of shop/public: index.php, css, js, images

Then fix the one path in public_html/index.php:

PHP
<?php
declare(strict_types=1);

require __DIR__ . '/../shop/vendor/autoload.php';
require __DIR__ . '/../shop/src/bootstrap.php';
  1. Route all requests through index.php with an .htaccess file in the web root (Apache and LiteSpeed, used by most shared hosts, read it):
Text
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
  1. Turn on HTTPS with the panel's free SSL option (usually Let's Encrypt or AutoSSL), then redirect all HTTP traffic to HTTPS.
  2. Add cron jobs in the panel for background scripts, and check file permissions: folders 755, files 644, and only storage/ writable by PHP. Never use 777.

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:

Terminal
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:

Terminal
sudo adduser deploy
sudo usermod -aG www-data deploy
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Then tell Nginx about the site in /etc/nginx/sites-available/shop and link it into sites-enabled:

Text
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:

Terminal
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d shop.example.in

Certbot 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:

Text
/var/www/shop/
  current -> releases/20261102101500
  releases/
    20261101183000/
    20261102101500/
  shared/
    .env
    storage/
Terminal
#!/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 -rf

set -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
<?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";
Terminal
curl -s https://shop.example.in/health.php
Output
{"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/, exposing config/ and .env.
  • Different PHP versions locally and on the server, discovered only when a page crashes.
  • chmod 777 to "fix" permission errors. Give the web server user write access to storage/ 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-dev dependencies, secrets and display_errors before every deployment.
  • Point the web root at public/; on shared hosting keep the app beside public_html if needed.
  • Route requests to index.php with .htaccess on Apache or try_files on 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 .env and storage/, switch a current symlink 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
Terminal
#!/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"
Output
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?

2. What does PHP-FPM do in an Nginx setup?

3. Why switch a current symlink instead of copying files over the live site?

4. A health-check page fails to connect to the database. What should it show?

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