This site runs on shared hosting: PHP, a MySQL database, and one .htaccess file that is the only part of the web server I get to configure. No root access, nothing to install, nothing to restart. It is the least fashionable way to put a site online, and for a portfolio and a blog it is the right one.
What it does not give you is a platform that covers for the code, so the code covers for itself. Here is how. Several of these lessons were learned on this very site.
Put the file’s age in the asset URL
The .htaccess file tells browsers to keep stylesheets and scripts for a year. Good for speed, bad for deploys. After the redesign, devices that had visited before kept the old stylesheet and drew the new markup with it: headings in the old uppercase style, and every class added since rendered as bare text. A desktop that had refetched looked fine, which hid the problem; phones hold their caches longest, and that is where it showed.
function asset_url(string $path): string
{
$path = '/' . ltrim($path, '/');
$mtime = @filemtime(APP_ROOT . $path);
return $mtime ? $path . '?v=' . $mtime : $path;
}
// Used as: asset_url('assets/css/style.css')
Change the file and its URL changes too, so a year-long cache can never outlive a deploy. No build step, nothing to remember.
Log every error, show none
A PHP warning printed into a page tells a visitor nothing useful and tells anyone curious far too much: file paths, pieces of queries, which library sits underneath. Hosts pick their own defaults, so the shared config file sets the policy itself:
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', $logFile); // in a folder the web server will not serve
Report everything, display nothing, write it all down. A visitor who meets a failure gets a plain sentence asking them to try again; the detail waits in the log for me.
Keep secrets where the server will not serve them
Database passwords, mail credentials and API keys live in a .env file that never enters version control. The config file reads it once into named settings. That file sits among the site’s other files, so the web server is told never to hand out any dotfile:
<FilesMatch "^\.">
Require all denied
</FilesMatch>
Then check it from outside, the way a stranger would, and expect a refusal. A rule you have not seen working is a rule you are hoping about.
Analytics must never take the site down
Every public page records the visit. For a while the tracker opened the database through the same include as the contact form, and that include answered a failed connection by ending the request with a server error. So a database hiccup could take every page offline, not just the numbers. Now the connection lives in a function that is allowed to fail:
function db_try_connect(?int $timeout = null): ?PDO
{
// $options includes PDO::ATTR_TIMEOUT when $timeout is given
try {
return new PDO($dsn, DB_USER, DB_PASS, $options);
} catch (PDOException $e) {
error_log('Database connection error: ' . $e->getMessage());
return null;
}
}
// In the tracker, on every public page:
$trackerPdo = db_try_connect(3);
if ($trackerPdo !== null) {
(new VisitorTracker($trackerPdo))->trackVisit();
}
The tracker waits a few seconds at most and skips the insert when there is no database. An outage costs a page view’s stats, not the page. The contact form still refuses to run without one, because it cannot do its job quietly. Decide for each feature whether it is the page or a passenger. Passengers do not get to crash the car.
Cron jobs that also answer to a URL
Two jobs run on a schedule. One deletes visit statistics older than the privacy page promises to keep them; the other fills in visitor locations in batches, so no page ever waits on a lookup. Some shared hosts will only let a scheduled job fetch a URL, so both share a bootstrap that works either way:
if (PHP_SAPI !== 'cli') {
$token = $_GET['token'] ?? '';
if (CRON_TOKEN === '' || !hash_equals(CRON_TOKEN, $token)) {
http_response_code(403);
exit('Forbidden');
}
}
From the command line the job just runs. Over HTTP it needs a long random token, and an unset token keeps the door shut instead of opening it, the same trap as the empty token on my contact form. A URL can be fetched twice, so each job is safe to repeat: a second purge finds nothing left to delete, and the location job only touches rows that are still blank.
One source for the pages and the sitemap
A hand-written sitemap is right on the day it is written. Here a rewrite rule hands /sitemap.xml to a PHP script:
RewriteRule ^sitemap\.xml$ sitemap.php [L]
Blog entries come from the same list of posts that builds the blog, so the two cannot disagree. A post written ahead of its date stays out of both until the day arrives. Static pages take their last-changed date from the file, and a revised post reports its revision date.
Check that .htaccess is actually applied
Most of what protects this site lives in .htaccess: the HTTPS redirect, the security headers, the rules that refuse to serve secrets and logs. If the server is not reading the file, all of it does nothing, silently, and the site looks exactly the same. A host can be set up to ignore the file entirely, and any block inside <IfModule> is skipped without a word when its module is missing. That makes the file portable and its failures invisible. So test from outside:
# Temporarily add to .htaccess: Redirect 302 /htaccess-test /
curl -sI https://example.com/htaccess-test | head -n 1 # 302, not 404
curl -sI https://example.com/ | grep -i x-content-type-options
curl -sI https://example.com/.env | head -n 1 # 403
The throwaway redirect proves the file is read, the header proves the module behind it is loaded, and the refusal proves the part that matters most. Then the test line comes out again.
Boring hosting, careful code
None of this needed a better server. Cache-busting is one function. The error policy is four lines. The tracker fix moved a connection into a function that is allowed to fail. Each habit is the code doing a job that a pricier platform might have done for it, and once the code does it, the platform matters much less.
Shared hosting is cheap, it is everywhere, and it will run plain PHP for years without asking anything of anyone. Most of what goes wrong on it can be handled in the code, which is good news, because the code is the part you control. It is also why my services page says plain PHP is sometimes the honest answer.