OPcache on shared hosting: why your changes don't show up for 5 minutes after deploy
After rsync the PHP files on the server are new, but the browser serves the old version. The cause: OPcache on shared. Fix via .user.ini in 5 minutes.
This is the story of one day of debugging that could have taken 5 minutes - if I had known earlier about .user.ini and opcache.validate_timestamps. Sat there for two hours, could not understand why PHP edits were not showing on the site. The file on SSH - new. In the browser - old. Re-rsynced - same thing. Cleared Beget CDN - did not help.
The cause is OPcache on shared hosting. Here is how to spot it and how to fix it, so you do not lose hours.
The short version
- On shared, OPcache by default does not check file mtime - it serves stale bytecode for 5-10 minutes after deploy.
- Symptom: rsync went through, the file on the server is fresh by
ls -la, but the browser shows old code.- Fix:
.user.iniin the site root withopcache.validate_timestamps=1andopcache.revalidate_freq=0.- Note: the first
.user.iniis picked up after 300 seconds (user_ini TTL). Then everything is real-time.
How the bug shows up
I was working on a fix for the metro page template. Wrote a PHP function render_metro_features(), pushed via rsync to Beget shared. Opened https://example.ru/uborka-metro-pushkinskaya/ - old version. Thought - browser cache. Hard reload (Ctrl+Shift+R), incognito tab. Same thing.
Went in via SSH, ran cat ~/example.ru/public_html/metro.php | grep render_metro_features. The function is there, new version. So the file on disk is fresh.
Did php -l metro.php - no syntax errors.
Did a direct curl request to the server, bypassing browser and CDN:
curl -H "Cache-Control: no-cache" -H "Pragma: no-cache" https://example.ru/uborka-metro-pushkinskaya/
Old HTML version came back. So it is not the browser, not the CDN. Pinging Beget support ‘something is broken on your end’ is not an option - other sites on their hosting are fine.
Opened phpinfo() via a utility page. And saw: opcache.enable On, opcache.validate_timestamps Off, opcache.revalidate_freq 300. There it is.
What OPcache is in one paragraph
When you open a PHP page, the server does three things: reads the source from disk, parses it into opcodes (internal representation), executes the opcodes. Parsing is the most expensive step - up to 50-70% of request time. OPcache stores already-parsed opcodes in the server’s shared memory. The next request to the same file skips the parsing - takes opcodes from memory and executes immediately.
On shared hosts OPcache is always on. That lets the provider keep three to five times more clients on one server. Downside - an invisible server-side cache you have to account for after every deploy.
Why shared doesn’t reset the cache itself
There are two modes. First - opcache.validate_timestamps=1. PHP does a stat() on the source on every request, compares mtime with the cached one. If the file changed - rereads and refreshes the cache. Downside - an extra disk check on every request (100 microseconds on an average site).
Second - opcache.validate_timestamps=0. No checks. The cache lives until an explicit opcache_reset() or until PHP-FPM restarts. That is the maximum-performance mode, used on serious production. Downside - you have to reset the cache manually after deploy.
On shared, opcache_reset() via PHP CLI is not available (often it is on the disabled functions list). You do not have access to PHP-FPM restart. So Beget and other shared providers compromise: validate_timestamps=0, but revalidate_freq=60-300. That means the cache checks mtime not on every request but every 1-5 minutes. Fresh code is picked up within that minute-five.
If during that window you are deploying and testing - the change is ‘invisible’. Five minutes later it appears suddenly. By then you have pushed a second change. And confusion starts about what came from where.
Fix via .user.ini
In the site root (where index.php is) you drop a .user.ini file with two lines:
opcache.validate_timestamps=1
opcache.revalidate_freq=0
What it does. validate_timestamps=1 - OPcache checks file mtime on every request. revalidate_freq=0 - actually every request, no looser. Both of these in .user.ini are supported (they have PHP_INI_PERDIR scope in the docs). You cannot change them globally on shared - but you can locally for your directory.
The catch is the TTL of .user.ini itself. PHP rereads it not every request but every 300 seconds by default. That is controlled by the global user_ini.cache_ttl setting, which you cannot change. So after you drop .user.ini on the server for the first time, you have to wait up to 5 minutes for PHP to pick it up. After that, real-time, no delays.
Sanity-check that it picked up - open a test PHP script with phpinfo(). You should see opcache.validate_timestamps Local Value: On. If still Off - wait, or force the server to reread ini files by hitting the site every 30 seconds.
What else to put in .user.ini
Since you are putting this file there - add other useful settings. Minimum for a site backend:
; OPcache sees edits right away
opcache.validate_timestamps=1
opcache.revalidate_freq=0
; Upload limits (if you have photo forms)
upload_max_filesize=20M
post_max_size=22M
max_input_vars=3000
; Time zone
date.timezone="Europe/Moscow"
; Sessions - where to write
session.cookie_httponly=1
session.cookie_samesite="Lax"
Only settings with PHP_INI_PERDIR or PHP_INI_USER scope will work. For example, memory_limit in .user.ini is ignored by Beget - in the general case it is PHP_INI_ALL, but in the shared config the provider locked it. If you need it raised - ask support.
When .user.ini doesn’t help
It happens that you put validate_timestamps=1, 10 minutes pass, the code is still old. Then possible causes:
- Beget CDN holds an HTML copy at the edge. If the page is not unique by cookie/query, it gets cached. Clear via the Beget panel, CDN section, ‘Clear cache’ button. Or temporarily turn CDN off for half an hour, look.
- Browser cached the HTML. Open in incognito or via curl without headers.
- You are editing the wrong file. Sometimes on shared there is an alias domain - the folder
~/example.ru/physically exists, but Nginx serves the domain from a different folder. Check vials -la /var/www/<user>/data/www/, usually a symlink there. I had this story once - lost a full day. opcache_invalidate()via PHP CLI is blocked. If you are trying to reset the cache from a cron script - it silently fails. On shared that function is usually indisable_functions. The only way left is the ini setting.
Alternative - versioned PHP files
As a last resort, if .user.ini does not help due to provider policy, there is a workaround. Deploy new PHP versions with version in the file name:
metro.php → metro_v2.php
And in .htaccess rewrite the routing:
RewriteRule ^uborka-metro-(.+)/?$ metro_v2.php?slug=$1 [L,QSA]
The old metro.php stays in OPcache, but nobody requests it. New requests hit metro_v2.php, which is not in cache yet - PHP parses it fresh. Five minutes later OPcache picks that up too, but by then you already see fresh code.
It is crude and does not scale, but sometimes saves the moment while you sort out the settings.
Post-deploy checklist on shared
- Right after rsync - hit a test page via curl with
no-cacheheaders. See old version - it is OPcache, go to.user.ini. See new - good, then check via browser. - Check
phpinfo()once per hosting - find out the values ofopcache.validate_timestampsandopcache.revalidate_freq. They differ between Beget, Reg.ru, Timeweb. - Drop
.user.iniright after the first deploy. Wait 5 minutes for pickup. After that all edits visible immediately. - CDN is a separate cache layer. If you use Beget CDN or Cloudflare, after major changes go to their panel and clear cache manually. Or set
Cache-Control: max-age=300in headers for fast invalidation.
The full case on building a custom site on shared with PHP 8.4 - in the article ‘50 days of SEO in B2B cleaning’. OPcache is one of eight shared-hosting traps I hit in the first weeks.
Related: CLS 0.377 → 0.002 in a day - another story of fast fixes with big impact.