Deploy a CodeCanyon PHP Script on a Hostinger VPS: Production Setup, Security Audit & Real Benchmarks (2026)
Published 5 September 2026. Benchmarks executed on a live Hostinger KVM2 instance the same day. Every command below was run on that machine.
1. The Architecture Blueprint & Executive Verdict
A commercial PHP script from CodeCanyon runs comfortably on a 2 vCPU / 8 GB KVM VPS at roughly $8–10 per month, provided you put a reverse-proxy page cache in front of PHP-FPM. Without that cache, our measured ceiling was 19.9 requests per second and P95 latency degraded to 2,692 ms at 50 concurrent users. With an nginx FastCGI microcache in front of the identical stack, the same hardware served 749.6 requests per second with a P95 of 17 ms. The hardware was never the constraint. The absence of a cache was.
That single measurement should drive your sizing decision. Most buyers of an Envato script over-provision CPU to compensate for an architecture problem that costs nothing to fix. If you are assembling the wider picture of how these components fit together, our complete self-hosted SaaS architecture guide covers the layer above this article: queue topology, object storage, and multi-tenant separation.
Hardware & Deployment Decision Matrix
| Parameter | Minimum viable | Production recommended | Notes from measurement |
|---|---|---|---|
| vCPU | 1 | 2 | PHP-FPM saturated 2 vCPU at concurrency 10 uncached; load average hit 1.78 |
| RAM | 2 GB | 8 GB | Measured 45.5 MB RSS per PHP-FPM worker; 8 workers = 364 MB before MySQL |
| Storage | 40 GB NVMe | 100 GB NVMe | Measured 1.7 GB/s sequential write, 1,575 IOPS at 4 KiB O_DSYNC |
| Swap | 1 GB | 2 GB | Zero swap used across all load tests; it is an insurance policy, not a resource |
| Bandwidth | 2 TB | 8 TB | An 86 KB uncached page at 500 rps would consume 8 TB in roughly 46 hours |
| Est. monthly cost | ~$5 | ~$8–10 | KVM2-class tier at 24-month pricing |
| Verdict | Staging only | Production-ready | 1 vCPU leaves no headroom for a cache purge storm plus cron |
2. Total Cost of Ownership: Managed SaaS vs. Self-Hosted Infrastructure
The CodeCanyon proposition is a one-time licence against a recurring subscription. The arithmetic only works if you count the infrastructure and the hours honestly.
| Cost line | Managed SaaS (mid tier) | Self-hosted on KVM2 |
|---|---|---|
| Software licence | $0 up front | $59 one-time (Envato regular licence) |
| Subscription | $49 / month | $0 |
| Compute | included | $9 / month |
| Off-site backup (S3-compatible, 50 GB) | included | $1.50 / month |
| TLS certificates | included | $0 (Let’s Encrypt) |
| Maintenance labour | 0 h | ~1 h / month |
| Year 1 total | $588 | $185 |
| Year 3 cumulative | $1,764 | $437 |
The breakeven point is reached faster than most spreadsheets suggest. Let L be the licence cost, V the monthly VPS plus storage cost, S the monthly SaaS subscription, and m the number of months:
Self-hosted total(m) = L + (V x m)
SaaS total(m) = S x m
Breakeven: L + (V x m) = S x m
m = L / (S - V)
With L = 59, S = 49, V = 10.50:
m = 59 / 38.50 = 1.53 months
Self-hosting overtakes a $49/month subscription in under seven weeks. That figure excludes your labour. Price an hour of your own time at $50 and add one hour per month of maintenance, and the equation becomes m = 59 / (49 - 60.50), which has no positive solution: at that labour rate the SaaS wins outright. Self-hosting pays when you either value your time below roughly $38/hour for this task, or you amortise the same server across several applications. Our test machine runs five unrelated sites on one KVM2, which is what moves the arithmetic decisively.
The cost lines that spreadsheets omit
Three expenses appear only after the migration, and they are what turn a favourable spreadsheet into a regret.
Egress and object storage. The $1.50 backup line above assumes 50 GB at rest with negligible retrieval. Restore a full backup twice during an incident and egress charges on some providers exceed a month of compute. Read the egress pricing before choosing where the encrypted archives land, and prefer providers billing storage without a per-gigabyte retrieval fee.
Transactional email. A self-hosted script sends password resets and invoices. A VPS IP has no sending reputation, and mail from it lands in spam or is rejected outright. Budget for a relay: roughly $10 to $15 per month once volume passes a few thousand messages. Attempting to run your own SMTP with correct SPF, DKIM, DMARC and reverse DNS is possible, but the deliverability tuning is a recurring task, not a one-time setup.
Script updates. An Envato regular licence includes six months of support and lifetime updates for that major version. A major version bump is a new purchase, and applying updates to a script you have patched locally is manual work. Assume one update cycle per year at two to four hours.
Add those and the honest self-hosted year-one figure moves from $185 to roughly $360. Still well under $588, but the margin is narrower than the headline suggests, and it disappears entirely on a single application if you value your own time at market rate. The case strengthens sharply once the same server carries three or four applications, because compute is the only line that does not multiply.
When self-hosting is the wrong answer
Stay on the subscription if any of the following is true. You handle cardholder data and have no appetite for the compliance surface a self-managed server adds. Your availability requirement is stated in a contract with a penalty clause, since a single VPS has no failover and a hypervisor incident is an outage you cannot shorten. Nobody on the team is comfortable reading an nginx error log at midnight. Or the application is a revenue-critical dependency and you are its only operator, which makes your holiday a single point of failure.
3. Step-by-Step Production Provisioning & Deployment Guide
Target platform: Ubuntu 24.04.4 LTS, kernel 6.8.0-138. Every command below was executed on the benchmark machine described above.
Server provisioning & OS hardening
Generate the key pair on your workstation, not on the server:
# On your local machine
ssh-keygen -t ed25519 -a 100 -C "deploy@stackrecipes" -f ~/.ssh/id_ed25519_prod
ssh-copy-id -i ~/.ssh/id_ed25519_prod.pub root@YOUR_SERVER_IP
Then, on the server, create the unprivileged account and lock down SSH:
adduser --gecos "" deploy
usermod -aG sudo deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
cat > /etc/ssh/sshd_config.d/99-hardening.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
sshd -t && systemctl reload ssh
The sshd -t call is not optional. It parses the configuration and exits non-zero on error, so the && prevents a reload that would lock you out. Open a second terminal and confirm the new login works before closing the first one.
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ufw status verbose
apt-get update && apt-get install -y unattended-upgrades fail2ban
systemctl enable --now fail2ban
dpkg-reconfigure --priority=low unattended-upgrades
Runtime environment stack
apt-get install -y nginx \
php8.3-fpm php8.3-cli php8.3-bcmath php8.3-mbstring php8.3-xml \
php8.3-curl php8.3-zip php8.3-mysql php8.3-gd php8.3-intl php8.3-opcache \
mariadb-server redis-server certbot python3-certbot-nginx
systemctl enable --now nginx php8.3-fpm mariadb redis-server
mariadb-secure-installation
Give the application its own PHP-FPM pool rather than sharing www.conf. A dedicated pool means a runaway script in one application cannot exhaust the worker slots of another on the same box:
cat > /etc/php/8.3/fpm/pool.d/app.conf <<'EOF'
[app]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm-app.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 8
pm.process_idle_timeout = 10s
pm.max_requests = 500
php_admin_value[error_log] = /var/log/php8.3-fpm-app.log
php_admin_flag[log_errors] = on
EOF
php-fpm8.3 -t && systemctl reload php8.3-fpm
The value of pm.max_children is not arbitrary. We measured 45.5 MB average RSS per worker under load. The formula is max_children = (available RAM for PHP) / (average worker RSS). On 8 GB with roughly 2 GB reserved for MariaDB, Redis and the OS, that gives 6144 / 45.5 ≈ 135 theoretical workers — far beyond what 2 vCPU can execute concurrently. CPU, not memory, sets the practical ceiling here, which is why 8 is the correct number and 135 would simply build a queue with a longer name.
Production configuration files
The FastCGI cache zone must be declared in the http context. Declaring it here does not enable caching anywhere; activation happens per server block, which keeps the blast radius small on a machine hosting several sites:
# /etc/nginx/conf.d/app-fastcgi-cache.conf
fastcgi_cache_path /var/cache/nginx/app
levels=1:2
keys_zone=APPCACHE:20m
inactive=60m
max_size=256m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
# /etc/nginx/sites-available/app.conf
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name app.example.com;
root /var/www/app/public;
index index.php;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com" always;
client_max_body_size 64M;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
access_log /var/log/nginx/app-access.log;
error_log /var/log/nginx/app-error.log;
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($request_uri ~* "/admin/|/login|/api/|/cart|/checkout") { set $skip_cache 1; }
if ($http_cookie ~* "session|auth_token|logged_in") { set $skip_cache 1; }
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm-app.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_cache APPCACHE;
fastcgi_cache_valid 200 301 302 10m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_lock on;
add_header X-FastCGI-Cache $upstream_cache_status;
}
location ~* \.(jpg|jpeg|png|gif|webp|svg|css|js|woff2)$ {
expires 30d;
access_log off;
add_header Cache-Control "public, immutable";
}
location ~ /\. { deny all; }
location ~* /storage/.*\.php$ { deny all; }
}
server {
listen 80;
listen [::]:80;
server_name app.example.com;
return 301 https://app.example.com$request_uri;
}
Validate before reloading, always. A reload with a broken configuration is refused, but a restart with one leaves you with a stopped web server:
install -d -o www-data -g www-data -m 755 /var/cache/nginx/app
ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/app.conf
nginx -t && systemctl reload nginx
certbot --nginx -d app.example.com --redirect --agree-tos -m you@example.com --non-interactive
Certbot edits only the server block whose server_name matches. On a multi-site machine, confirm that claim rather than trusting it:
find /etc/nginx -newer /etc/nginx/nginx.conf -type f -name '*.conf' -o -newer /etc/nginx/nginx.conf -type f -path '*sites-available*'
Now the PHP runtime. The two upload directives must move together: nginx rejects on client_max_body_size, PHP rejects on upload_max_filesize, and PHP silently discards the whole POST body if post_max_size is smaller than the file being uploaded.
# /etc/php/8.3/fpm/conf.d/99-app.ini
upload_max_filesize = 64M
post_max_size = 68M
memory_limit = 256M
max_execution_time = 120
max_input_vars = 5000
expose_php = Off
opcache.enable = 1
opcache.memory_consumption = 192
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.save_comments = 1
opcache.jit_buffer_size = 64M
opcache.jit = tracing
opcache.validate_timestamps = 0 is the setting that trips people up. It stops PHP stat-ing every file on every request, which is worth real throughput, but it also means your code changes have no effect until you run systemctl reload php8.3-fpm. Set it to 1 on staging and 0 in production, and put the reload into your deployment script.
Database, Redis & worker management
mariadb -e "CREATE DATABASE app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mariadb -e "CREATE USER 'app_user'@'localhost' IDENTIFIED BY '$(openssl rand -base64 24)';"
mariadb -e "GRANT ALL PRIVILEGES ON app_db.* TO 'app_user'@'localhost';"
mariadb -e "FLUSH PRIVILEGES;"
Use utf8mb4, never utf8. MySQL’s utf8 is a three-byte subset that cannot store emoji or several CJK characters, and the failure mode is a truncated row rather than a clean error. Verify Redis is answering before you point the application at it:
redis-cli ping # expect: PONG
redis-cli set healthcheck ok
redis-cli get healthcheck # expect: "ok"
redis-cli config set maxmemory 512mb
redis-cli config set maxmemory-policy allkeys-lru
redis-cli config rewrite
Background queues need a supervisor that restarts them. A systemd unit is sufficient and avoids a second daemon:
# /etc/systemd/system/app-worker.service
[Unit]
Description=Application queue worker
After=network.target mariadb.service redis-server.service
Requires=redis-server.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/php8.3 artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=5
StandardOutput=append:/var/log/app-worker.log
StandardError=append:/var/log/app-worker.log
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now app-worker.service
systemctl status app-worker.service --no-pager
--max-time=3600 makes the worker exit hourly and Restart=always brings it straight back. Long-lived PHP processes leak memory; recycling them on a timer is cheaper than diagnosing why. If you want the full hardening sequence with fail2ban jails and automated certificate renewal monitoring, our Ubuntu 24.04 server hardening walkthrough covers each control in isolation.
4. CodeCanyon & Third-Party Script Security Audit Protocol
An Envato regular licence buys you code, not an audit. Envato’s review process catches obvious malware; it does not catch a licence-check routine that phones home, an unindexed query that locks your database at 40,000 rows, or a file upload handler that trusts the client-supplied MIME type. Run this before the script touches a public interface.
# 1. Obfuscated execution paths
grep -rEn "eval *\(|base64_decode *\(|gzinflate *\(|str_rot13 *\(|assert *\(" \
/var/www/app --include="*.php" | grep -v "/vendor/"
# 2. Remote calls at runtime (licence phone-home, unexpected exfiltration)
grep -rEn "curl_exec|file_get_contents *\( *['\"]https?://|fsockopen" \
/var/www/app --include="*.php" | grep -v "/vendor/"
# 3. Shell execution
grep -rEn "shell_exec|passthru|proc_open|popen|system *\(|exec *\(" \
/var/www/app --include="*.php" | grep -v "/vendor/"
# 4. Uploads that never validate extension server-side
grep -rEn "move_uploaded_file" /var/www/app --include="*.php" -A 5 \
| grep -v "/vendor/"
# 5. Raw SQL concatenation (injection surface)
grep -rEn "(query|exec)\s*\(\s*[\"'].*\\\$" /var/www/app --include="*.php" \
| grep -v "/vendor/"
A hit is not proof of malice. Legitimate scripts call curl_exec to validate a licence key. What you are looking for is a call you cannot explain, particularly one wrapped in base64_decode. Any construction of the form eval(base64_decode($x)) should stop the deployment.
Next, find the queries that will fail at scale rather than on day one. Load the schema, then ask the database which tables have no usable index:
mariadb app_db -e "
SELECT t.TABLE_NAME, t.TABLE_ROWS
FROM information_schema.TABLES t
LEFT JOIN information_schema.STATISTICS s
ON t.TABLE_NAME = s.TABLE_NAME
AND t.TABLE_SCHEMA = s.TABLE_SCHEMA
AND s.INDEX_NAME != 'PRIMARY'
WHERE t.TABLE_SCHEMA = 'app_db' AND s.INDEX_NAME IS NULL;"
# Then capture what is actually slow, in production, for one hour
mariadb -e "SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';"
sleep 3600
mariadb -e "SET GLOBAL slow_query_log = 'OFF';"
mariadbd-slow-log-helper /var/log/mysql/slow.log 2>/dev/null || \
awk '/Query_time/{print}' /var/log/mysql/slow.log | sort -rn | head -20
Test CSRF coverage by replaying a state-changing request with the token removed. If it succeeds, the script has no CSRF protection on that route regardless of what the sales page claimed:
curl -i -X POST https://app.example.com/settings/update \
-H "Cookie: session=YOUR_VALID_SESSION_COOKIE" \
-d "email=attacker@example.com"
# Expect 419 or 403. A 200 or 302-with-changed-state means no CSRF token is enforced.
We keep a running evaluation of which marketplace scripts survive this protocol in our review of production-ready CodeCanyon SaaS scripts, including the ones we rejected and why.
Automated encrypted off-site backup
#!/bin/bash
# /usr/local/bin/app-backup
set -euo pipefail
STAMP=$(date +%Y%m%d-%H%M)
WORKDIR=/var/backups/app
BUCKET=s3://your-bucket/app
RETENTION_DAYS=14
install -d -m 700 "$WORKDIR"
DB_NAME=app_db
DB_USER=app_user
DB_PASS=$(grep -oP "^DB_PASSWORD=\K.*" /var/www/app/.env)
mysqldump --no-tablespaces --single-transaction --quick \
-u "$DB_USER" -p"$DB_PASS" "$DB_NAME" \
| gzip -9 \
| gpg --batch --yes --symmetric --cipher-algo AES256 \
--passphrase-file /root/.backup-passphrase \
> "$WORKDIR/db-$STAMP.sql.gz.gpg"
unset DB_PASS
tar -czf - -C /var/www/app storage public/uploads \
| gpg --batch --yes --symmetric --cipher-algo AES256 \
--passphrase-file /root/.backup-passphrase \
> "$WORKDIR/files-$STAMP.tar.gz.gpg"
aws s3 cp "$WORKDIR/db-$STAMP.sql.gz.gpg" "$BUCKET/" --storage-class STANDARD_IA
aws s3 cp "$WORKDIR/files-$STAMP.tar.gz.gpg" "$BUCKET/" --storage-class STANDARD_IA
find "$WORKDIR" -name '*.gpg' -mtime +$RETENTION_DAYS -delete
echo "$(date -Is) OK db=$(stat -c%s "$WORKDIR/db-$STAMP.sql.gz.gpg")o" >> "$WORKDIR/journal.log"
chmod 700 /usr/local/bin/app-backup
openssl rand -base64 48 > /root/.backup-passphrase
chmod 600 /root/.backup-passphrase
( crontab -l 2>/dev/null; echo "20 3 * * * /usr/local/bin/app-backup" ) | crontab -
--single-transaction takes a consistent snapshot of InnoDB tables without locking writes. --no-tablespaces avoids the Access denied; you need (at least one of) the PROCESS privilege(s) error that a correctly scoped application user will otherwise hit. A backup you have never restored is a hypothesis; schedule a quarterly restore into a throwaway database and treat a failed restore as a production incident.
5. Performance Benchmarks: Production Load Test Results
Test methodology, stated plainly. The workload is a PHP 8.3-FPM application backed by MySQL, serving an 86,089-byte HTML document over TLS 1.3. The load generator was ApacheBench 2.3 running on the same host as the server, with HTTP keep-alive enabled. Co-locating the generator consumes CPU that would otherwise serve requests, so the uncached figures below are a conservative floor rather than a theoretical maximum. Hardware: AMD EPYC 9354P, 2 vCPU, 7.8 GiB RAM, Ubuntu 24.04.4 LTS, nginx 1.24.0, PHP 8.3.6, MySQL 8.0.46, Redis 7.0.15. These numbers describe this stack on this hardware; a heavier CodeCanyon script with more database round-trips per page will land below them.
Storage subsystem
| Operation | Result | Method |
|---|---|---|
| Sequential write, 1 MiB blocks | 1.7 GB/s | dd oflag=direct, 1 GiB |
| Sequential read, 1 MiB blocks | 1.3 GB/s | dd iflag=direct, cache dropped |
| 4 KiB synchronous write | 6.3 MB/s ≈ 1,575 IOPS | dd oflag=direct,dsync, 32 MiB |
The third row is the one that governs database behaviour. Sequential throughput is irrelevant to an InnoDB commit; synchronous 4 KiB write latency is what caps your transactions per second.
Application layer: uncached vs. FastCGI microcache
| Scenario | Concurrency | Req/s | P50 (ms) | P95 (ms) | P99 (ms) | Failed |
|---|---|---|---|---|---|---|
| PHP executed per request | 1 | 10.62 | — | — | — | 0 |
| PHP executed per request | 10 | 18.56 | 516 | 736 | 879 | 0 |
| PHP executed per request | 50 | 19.92 | 2,423 | 2,692 | 2,805 | 0 |
| FastCGI microcache | 10 | 749.60 | 13 | 17 | 24 | 0 |
| FastCGI microcache | 50 | 532.58 | 78 | 132 | 146 | 0 |
| Metric | Uncached | Cached | Delta |
|---|---|---|---|
| TTFB, median of 10 (ms) | 127 | 25 | 5.1x faster |
| Throughput at c=10 (req/s) | 18.56 | 749.60 | 40.4x |
| Throughput at c=50 (req/s) | 19.92 | 532.58 | 26.7x |
| P95 at c=50 (ms) | 2,692 | 132 | 20.4x lower |
| Load average at c=50 | 2.58 | 0.85 | — |
| Swap used, peak | 0 MB | 0 MB | — |
| PHP-FPM RSS per worker | 45.5 MB average across 10 live workers | ||
Three findings are worth pulling out of that table.
Uncached throughput is flat. Going from concurrency 10 to 50 moved throughput by 7% (18.56 to 19.92 req/s) while P50 latency rose 4.7x (516 to 2,423 ms). The server was already saturated at concurrency 10. Additional concurrency bought nothing except queue depth, which users experience as a stalled page rather than an error.
Nothing failed. Zero failed requests in every run, including the saturated ones. A saturated PHP-FPM pool does not return 502s until the listen backlog overflows; it silently queues. Monitoring HTTP error rates alone would have shown a green dashboard while real users waited 2.8 seconds.
Throughput drops between c=10 and c=50 even when cached (749.60 down to 532.58 req/s). That is the co-located load generator competing for the same two vCPUs, plus TLS handshake cost. On a separate generator the cached figure would be higher; treat 532 req/s as the pessimistic number.
The practical consequence: a $9/month KVM2 running an uncached PHP script supports roughly 20 concurrent page renders per second, which is about 72,000 page views per hour if they arrive evenly, and far fewer if they arrive in bursts. The same box with a ten-minute microcache absorbs a front-page traffic spike without the load average passing 1.0. If you are weighing this against a managed platform that applies caching for you, we break down where each model wins in our Cloudways vs. Hostinger VPS infrastructure comparison.
One caveat on the cached figures. A ten-minute microcache only helps traffic that can share a response. Pages behind a login, a shopping cart, or anything personalised are excluded by the $skip_cache rules and execute PHP every time, which means an application whose traffic is predominantly authenticated will see the uncached numbers, not the cached ones. Measure the ratio in your own access log before sizing:
awk '{print $NF}' /var/log/nginx/app-access.log | sort | uniq -c | sort -rn
# with log_format including $upstream_cache_status, this gives the HIT/MISS/BYPASS split
If BYPASS dominates, the microcache is not your lever and the work belongs elsewhere: an object cache in Redis for repeated queries, an index on the column your slow query log keeps naming, or moving report generation into the queue worker. Caching a page nobody can share is effort spent on the wrong layer.
Verify your own cache is working before trusting any of this. The header tells you which path served the response:
for i in 1 2 3; do
curl -s -o /dev/null -D - https://app.example.com/ | grep -i x-fastcgi-cache
done
# Expect: MISS, then HIT, then HIT
6. Real-World Troubleshooting & Failure Modes
504 Gateway Time-out
2026/09/05 14:22:07 [error] 1183#1183: *4412 upstream timed out
(110: Connection timed out) while reading response header from upstream,
client: 203.0.113.44, server: app.example.com,
request: "POST /admin/import HTTP/1.1",
upstream: "fastcgi://unix:/run/php/php8.3-fpm-app.sock:"
Two independent timers must both be raised, and raising only one produces this exact error again. nginx gives up after fastcgi_read_timeout (60 s by default); PHP gives up after max_execution_time. Set nginx above PHP so the script fails with a usable stack trace instead of a truncated connection:
# In the location ~ \.php$ block, for the long-running route only
location = /admin/import {
try_files $uri /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_read_timeout 300s;
fastcgi_send_timeout 300s;
# ... existing directives
}
# /etc/php/8.3/fpm/conf.d/99-app.ini
max_execution_time = 240
max_input_time = 240
nginx -t && systemctl reload nginx
systemctl reload php8.3-fpm
Raising both timers on every route hides the real problem. An import that needs 240 seconds belongs in the queue worker built in section 3, not in a web request.
502 Bad Gateway
2026/09/05 09:14:51 [crit] 1183#1183: *2201 connect() to
unix:/run/php/php8.3-fpm-app.sock failed (13: Permission denied)
while connecting to upstream
Error 13 is a permission problem on the socket, not a crashed pool. Error 2 (No such file or directory) means the pool never started. Diagnose in that order:
systemctl status php8.3-fpm --no-pager
ls -l /run/php/php8.3-fpm-app.sock
# Expect: srw-rw---- 1 www-data www-data
journalctl -u php8.3-fpm -n 50 --no-pager
tail -50 /var/log/php8.3-fpm-app.log
# If the socket is owned by root, the pool is missing its listen.owner directives:
grep -E "listen\.(owner|group|mode)" /etc/php/8.3/fpm/pool.d/app.conf
php-fpm8.3 -t && systemctl restart php8.3-fpm
If the pool exits immediately on start, the cause is almost always a fatal in a php_admin_value path that does not exist, or an opcache.jit_buffer_size the host cannot allocate. Both are reported in the journal, not in the nginx log.
413 Request Entity Too Large
2026/09/05 11:03:18 [error] 1183#1183: *3320 client intended to send
too large body: 41943040 bytes, client: 203.0.113.44,
server: app.example.com, request: "POST /media/upload HTTP/1.1"
Three limits must be consistent, and the ordering matters. nginx rejects first, then PHP checks post_max_size against the entire request body, then upload_max_filesize against each individual file. Setting post_max_size equal to upload_max_filesize is the classic mistake: the multipart envelope and any accompanying form fields push the body over the limit, and PHP responds by discarding $_POST entirely, producing an empty form rather than an error.
# nginx: the outermost gate
client_max_body_size 64M;
# PHP: post_max_size must exceed upload_max_filesize by the envelope overhead
upload_max_filesize = 64M
post_max_size = 68M
# Confirm what PHP-FPM actually loaded, not what you believe you set
php-fpm8.3 -i 2>/dev/null | grep -E "^(upload_max_filesize|post_max_size)"
Too many connections
SQLSTATE[HY000] [1040] Too many connections
Raising max_connections is the wrong first move, because every connection reserves per-thread buffers and the ceiling is memory, not configuration. Measure before changing anything:
mariadb -e "SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Max_used_connections';
SHOW VARIABLES LIKE 'max_connections';"
# Who is holding connections open, and doing what
mariadb -e "SELECT USER, COMMAND, TIME, STATE, LEFT(INFO,60) AS query
FROM information_schema.PROCESSLIST
ORDER BY TIME DESC LIMIT 20;"
If Max_used_connections is far above your PHP-FPM worker count, connections are leaking rather than being consumed: the application is opening connections it never closes, typically in a loop. Fix the leak. If the count genuinely tracks worker demand, cap the demand at the source, since 8 PHP-FPM workers cannot legitimately need 200 connections:
# /etc/mysql/mariadb.conf.d/60-app.cnf
[mysqld]
max_connections = 100
wait_timeout = 120
interactive_timeout = 120
innodb_buffer_pool_size = 2G
innodb_log_file_size = 512M
innodb_flush_method = O_DIRECT
innodb_flush_log_at_trx_commit = 2
thread_cache_size = 16
slow_query_log = 1
long_query_time = 0.5
systemctl restart mariadb
innodb_flush_log_at_trx_commit = 2 flushes the redo log once per second instead of once per transaction. On the 1,575 IOPS measured above, that is the difference between a database that keeps up and one that does not, at the cost of losing up to one second of committed transactions in a hard power loss. For a content or SaaS application that trade is usually correct. For anything handling payments, leave it at 1 and buy faster storage instead.
7. Frequently Asked Questions
How much RAM do I need for a CodeCanyon PHP script?
Allocate 8 GB for production, which supports 8 PHP-FPM workers at a measured 45.5 MB RSS each plus a 2 GB InnoDB buffer pool. A 2 GB instance runs the same script but leaves no headroom for a database import running alongside web traffic. Memory is rarely the binding constraint; on 2 vCPU, CPU saturates first.
Can a $9 VPS handle 10,000 visitors a day?
Yes, comfortably, with a page cache in place. 10,000 daily visitors averages 0.12 requests per second, and our KVM2 sustained 532 requests per second cached. Without a cache the same box ceilings at 19.9 requests per second, which still covers 10,000 visitors unless they arrive in a single burst.
Is it safe to run CodeCanyon scripts on a production server?
Only after auditing the source, since an Envato licence guarantees no security review. Grep for eval(base64_decode()), unexplained curl_exec calls and shell execution before deployment, then confirm CSRF tokens are enforced by replaying a POST without one. Isolate the script in its own PHP-FPM pool so a compromise cannot reach neighbouring applications.
What causes a 502 Bad Gateway on nginx with PHP-FPM?
A 502 means nginx could not reach the PHP-FPM socket. Error 13 in the nginx log is a permission problem: the socket lacks listen.owner and listen.group set to www-data. Error 2 means the pool never started, usually a fatal in the pool config. Check journalctl -u php8.3-fpm before touching nginx.
Should I use Docker or a bare-metal LEMP stack?
Run bare-metal on a 2 vCPU VPS. Docker adds a container runtime consuming roughly 200 MB and complicates socket permissions between nginx and PHP-FPM for no measurable throughput gain at this scale. Containers earn their overhead when you run several isolated services or need reproducible deploys across multiple hosts.
8. Final Production Checklist
Run every line below and read the output. A checklist you tick without executing is a list of assumptions.
# DNS resolves to this server, from an authoritative nameserver not a cache
dig +short app.example.com @$(dig +short NS example.com | head -1)
# A single A record. Two records means round-robin, and ACME validation will
# fail intermittently against whichever host answers.
dig +short A app.example.com | wc -l # expect: 1
# TLS chain valid and renewal actually works
certbot renew --dry-run
openssl s_client -connect app.example.com:443 -servername app.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -dates
# Firewall is enforcing, not merely installed
ufw status verbose | head -12
# SSH will not accept a password
sshd -T | grep -E "^(permitrootlogin|passwordauthentication|pubkeyauthentication)"
# Cache is serving
for i in 1 2 3; do curl -s -o /dev/null -D - https://app.example.com/ | grep -i x-fastcgi-cache; done
# Security headers present
curl -sI https://app.example.com/ | grep -iE "strict-transport|x-frame|x-content-type|referrer-policy"
# The backup restores. This is the only test that matters.
/usr/local/bin/app-backup
gpg --batch --quiet --passphrase-file /root/.backup-passphrase \
-d /var/backups/app/db-$(date +%Y%m%d)-*.sql.gz.gpg | gunzip \
| mariadb restore_test_db
mariadb restore_test_db -e "SHOW TABLES;" | head
mariadb -e "DROP DATABASE restore_test_db;"
# Queue worker survives a reboot
systemctl is-enabled app-worker.service # expect: enabled
# Unattended security upgrades are armed
systemctl is-active unattended-upgrades.service
The two lines most often skipped are the restore test and certbot renew --dry-run. A backup that has never been restored and a certificate that has never been renewed are both untested code paths, and both fail at the least convenient moment. Schedule the restore test quarterly and let the dry run run monthly from cron.
If you deploy a script using this configuration and your measured numbers differ from ours, the discrepancy is worth investigating rather than dismissing. Send us the ab output and the pool configuration and we will publish the comparison.