This repository contains infrastructure code behind Bitrix-based site of my father's metal decking business operating in multiple cities.
It's a Bitrix website completely enclosed within docker-compose to be as portable and maintainable as possible, and a set of scripts around its maintenance like dev site redeploy or production site backup.
flowchart TB
User["Browser"] -->|"HTTP/3, TLS 1.3,<br>Brotli"| Nginx
subgraph Docker["Docker Compose"]
Nginx["Nginx<br>(brotli + lua + HTTP/3)"]
Nginx -->|"FastCGI :9000"| PHP["PHP-FPM 8.4"]
Nginx -->|"static files"| Web["Web Files<br>prod / dev"]
PHP -->|"Unix socket"| MySQL[("Percona MySQL 8.4<br>(socket-only, no TCP)")]
PHP --> Memcached["Memcached<br>Cache (2 GB)"]
PHP --> MemSessions["Memcached<br>Sessions (128 MB)"]
PHP --> Web
PHPCron["PHP Cron<br>(agents, exports,<br>sitemaps)"] -->|"Unix socket"| MySQL
PHPCron --> Memcached
PHPCron --> MemSessions
PHPCron --> Web
subgraph Optional["Optional Services (profiles)"]
Certbot["DNSroboCert<br>(Let's Encrypt)"]
Zabbix["Zabbix Agent 2"]
Adminer["Adminer"]
Updater["Updater<br>(webhooks)"]
FTP["Pure-FTPD"]
end
Zabbix -->|"monitor"| MySQL
Zabbix -->|"monitor"| Nginx
Adminer -->|"Unix socket"| MySQL
end
subgraph HostCron["Host Cron"]
Backup["Backups<br>(duplicity + mysqldump)"]
Minify["JS/CSS Minify<br>(hourly)"]
ImgOpt["Image Optimisation<br>(weekly)"]
end
Backup -->|"incremental + dumps"| S3[("Yandex S3")]
Certbot -->|"DNS-01 challenge"| YcDNS["Yandex Cloud DNS"]
subgraph Regions["Domains"]
MSK["favor-group.ru"]
SPB["spb.favor-group.ru"]
Tula["tula.favor-group.ru"]
Dev["dev.favor-group.ru"]
CDN["static.cdn-favor-group.ru"]
end
Regions --> Nginx
The site serves three regions (Moscow, St Petersburg, Tula) via subdomains, each with its own robots.txt, sitemap, redirect map, and product export feeds. All traffic goes through a single nginx instance with HTTP/3 (QUIC), brotli compression, and multi-layer bot detection. MySQL is accessible only via Unix socket (no TCP port exposed). Backups run to Yandex Object Storage: incremental file backups via duplicity daily, MySQL dumps twice daily.
You bet! Here is a performance on Yandex.Cloud server with Intel Cascade Lake 8 vCPUs, 16Gb of RAM and 120Gb SSD 4000 read\write IOPS and 60Mb/s bandwidth.
- Nginx (ghcr.io/paskal/nginx) with brotli, HTTP/3 (QUIC) and Lua modules — proxies requests to php-fpm and serves static assets directly
- php-fpm 8.3 / 8.4 / 8.5 (ghcr.io/paskal/bitrix-php) for Bitrix with msmtp for mail sending
- Percona MySQL 8.4 because of its monitoring capabilities
- memcached for Bitrix cache and user sessions
The site serves three cities — Moscow (favor-group.ru), Saint Petersburg (spb.favor-group.ru) and Tula (tula.favor-group.ru) — from a single Bitrix installation, database and document root. The Bitrix aspro.max module handles region-aware content, while nginx and cron scripts handle the SEO layer.
How multi-region SEO works
- robots.txt — nginx rewrites
/robots.txtto/aspro_regions/robots/robots_$host.txt, so each subdomain gets its own file. A cron script (alter-robots-txt.sh, every 10 minutes, lives in the private overlay) patches these files after Bitrix regenerates them: Moscow indexes everything, SPb blocks/info/blog/(centralised on Moscow to avoid duplicate content), Tula additionally blocks/montag/and/projects/which don't exist for that region. - sitemaps — nginx rewrites
/sitemap*.xmlto/aspro_regions/sitemap/sitemap*_$host.xml. Four cron jobs generate them nightly:sitemap.bitrix.php,sitemap.aspro.php,sitemap.offers.phpandsitemap.regions.php. - redirect maps —
nginx/sites/redirects-map.confin the private overlay contains fourmapblocks: one per region ($new_uri_msk,$new_uri_spb,$new_uri_tula) for region-specific redirects (e.g. Tula bounces all/montag/and/projects/URLs to Moscow), plus a global$new_urimap for site-wide URL cleanup.
Safari's Intelligent Tracking Prevention (ITP) limits cookies set by JavaScript to 7 days (24 hours in some cases). This means the Metrika visitor identifier (_ym_uid) expires between visits, causing returning visitors to appear as new ones in analytics. Following Yandex's official recommendation, nginx re-sets the Metrika cookies (_ym_uid, _ym_d, _ym_ucs) server-side via Set-Cookie headers with a 1-year lifetime — browsers respect the full expiry for server-set cookies.
Implementation details
The implementation uses nginx map blocks (config/nginx/conf.d/metrika-cookies.conf) rather than if directives to avoid the "if is evil" problem — using add_header inside an if block replaces all parent-level headers, which would drop Cache-Control, security headers and CSP from static file responses. When the cookie is absent the map resolves to an empty string and no header is emitted. The headers are emitted only on document responses (the PHP location), never on static assets — Safari refuses to disk-cache any response carrying Set-Cookie.
The feature is on by default: the cookie domain is auto-derived from the host (last two labels), and the headers are only emitted for visitors that already carry a _ym_uid cookie, so sites without Metrika are unaffected. Sites on multi-level public suffixes (.co.uk, .com.tr) should pin the domain via a private/nginx/metrika-domain.map entry.
- PHP cron container (
php-cron) with same settings as PHP serving web requests - adminer (
adminer) as phpMyAdmin alternative for work with MySQL - pure-ftpd (
ftp) for FTP access - DNSroboCert (
certbot) for Let's Encrypt HTTPS certificate generation - zabbix-agent2 (
zabbix-agent, ghcr.io/paskal/zabbix-agent2) for monitoring - Webhooks server (
updater) for automated tasks.
These run on the host machine outside Docker, scheduled via config/cron/host.cron:
- JS/CSS minification — runs hourly via
tdewolff/minifyDocker image onweb/prod/localandweb/dev/local, producing.min.js/.min.cssfiles - Image optimisation — runs weekly (Saturday night) via
scripts/optimise-images.sh, processing PNG (optipng + advpng), JPEG (jpegoptim), WebP (cwebp) and GIF (gifsicle) inweb/prod/upload. Uses a SQLite database to track already-processed files and avoid redundant work - Log rotation — configured in
config/logrotate/for nginx (weekly for production access logs at 100 MB minimum, monthly for others) and PHP (monthly for error, cron and msmtp logs). Nginx logs are reopened vianginx -s reopen, PHP-FPM viaUSR1signal
Bitrix sites accumulate type-strictness bugs that PHP 8.x will tolerate at parse time but blow up at runtime — methods like mysqli::real_escape_string() started rejecting non-string arguments in 8.x, and a Bitrix codebase that worked for years on 7.x can have dozens of latent TypeErrors waiting for a specific code path to fire. PHPStan catches them, but only if it's run regularly against the deployed code (not your local working copy).
This infrastructure runs PHPStan weekly against the prod Bitrix tree, counts the findings in code you own, writes the count to a file, and lets the Zabbix agent read it via system.run — so a non-zero count becomes a regular monitoring alert instead of being discovered six months later in Sentry.
flowchart LR
Cron["Host cron<br>Mon 04:30 UTC"] -->|"flock-guarded"| Script["scripts/<br>phpstan-scan.sh"]
Script -->|"curl -sLz<br>(self-updating)"| Phar["phpstan.phar"]
Script -->|"docker exec php"| PHPStan["PHPStan analyse<br>--error-format=checkstyle"]
PHPStan -->|"parse XML,<br>emit JSON + count"| Files["logs/phpstan/<br>owned-latest.json<br>owned_errors_count.txt"]
Files -->|"system.run cat"| Agent["zabbix-agent2<br>(read-only mount)"]
Agent -->|"item delay 5m"| Trigger["Zabbix trigger:<br>last() <> 0"]
Design — drive to zero, no baseline file. The metric is the number of errors in code you wrote, and the target is zero. There is no baseline JSON, no fingerprint diff, no rebaseline ritual. If a PHPStan upgrade adds a new rule that surfaces new findings, you fix them or add a scoped ignoreErrors entry to the neon — the same workflow as a regression caught the normal way. This is why the PHAR is allowed to auto-update on every run (curl -sLz does a conditional GET, zero traffic when unchanged).
Two scopes:
- Owned (
phpstan-owned.neon, alerted): code you wrote —local/,bitrix/php_interface/init.php,bitrix/php_interface/include. This is what Zabbix watches. - Diagnostic (
phpstan-diagnostic.neon, manual): broader sweep including Bitrix-injected scaffold. Run on-demand with./scripts/phpstan-scan.sh --diagnosticwhen the owned count jumps weirdly and you want to check whether a path change accidentally scoped in vendor code. Never alerted on.
Both scopes use scanDirectories against the live /web/prod/bitrix/modules tree for symbol resolution — no Bitrix stubs to maintain, always in sync with the deployed version.
File locations:
| Path | Purpose |
|---|---|
scripts/phpstan-scan.sh |
Entrypoint script (flock-guarded, self-updates the PHAR, runs in php container) |
private/phpstan/phpstan-owned.neon |
Scope + rule config for the alerted scan (copy from phpstan-owned.neon.example and adapt) |
private/phpstan/phpstan-owned.neon.example |
Generic starting point — copy to phpstan-owned.neon and adapt paths |
private/phpstan/phpstan-diagnostic.neon |
Broader scope for manual troubleshooting (site-specific; lives in private repo) |
config/zabbix/templates/phpstan-monitoring.yaml |
Zabbix 7.4 template (three items, three triggers — count + freshness + failure-marker) |
logs/phpstan/owned_errors_count.txt |
Single integer the Zabbix agent reads |
logs/phpstan/owned-latest.json |
Full findings, file + line + identifier — open this when the trigger fires |
How to enable in your own fork:
- Mount the neon configs and log directory.
docker-compose.ymlalready wires this up —private/phpstanis mounted read-only into thephpandphp-croncontainers at/phpstan, andlogs/phpstanis mounted read-write into both PHP containers and read-only intozabbix-agent. Create the host log directory before first start, matching the UID/GID used by yourphpcontainer'swww-datauser:mkdir -p logs/phpstan && sudo chown $(docker exec php id -u www-data):$(docker exec php id -g www-data) logs/phpstan && sudo chmod 2775 logs/phpstan. In this repo's base image that's UID 1000; verify withdocker exec php id www-dataif you've swapped to a different image. The setgid bit keeps group inheritance for files PHPStan writes. If your PHP container is not namedphp/php-cron, also edit the twodocker exec -u www-data … phplines inscripts/phpstan-scan.shto match your container names. - Adapt the neon paths to your tree. Both neons reference
/web/prod/local,/web/prod/bitrix/php_interface/...— fine for the layout this repo assumes, but change them if yourweb/prodlives elsewhere. TheexcludePathslist is curated to skip third-party module trees that ship insidephp_interface/include/on this codebase; review and prune for yours. - Wire the cron entry. Already present in
config/cron/host.cron:30 4 * * 1 root cd $INFRA_DIR && ./scripts/phpstan-scan.sh >>/web/logs/phpstan/cron.log 2>&1. Weekly Monday 04:30 UTC. Adjust to suit your low-traffic window. Cron stdout/stderr is appended tologs/phpstan/cron.logso the diagnostic prints around a failed run survive for post-mortem (the script writes sentinels for Zabbix, but the cron-log line tells you which run died and how far it got). - Import the Zabbix template. Via the Zabbix API (recommended — repeatable, no manual UI clicks):
Then link the
from zabbix_utils import ZabbixAPI import os api = ZabbixAPI(url=os.environ["ZABBIX_URL"]) api.login(token=os.environ["ZABBIX_TOKEN"]) with open("config/zabbix/templates/phpstan-monitoring.yaml") as f: api.configuration.import_( source=f.read(), format="yaml", rules={ "templates": {"createMissing": True, "updateExisting": True}, "items": {"createMissing": True, "updateExisting": True}, "triggers": {"createMissing": True, "updateExisting": True}, "template_groups": {"createMissing": True}, }, )
PHPStan monitoringtemplate to the host running your zabbix-agent (host.updatewith the existing templates list preserved, plus the new templateid appended —host.updatereplaces the list rather than appending). - Stabilise before linking the template to a host. All three triggers ship
status: ENABLEDso re-imports stay healthy without an extra re-arm step. The flip-side is that linking the template before the system has scanned once will fire the count/freshness triggers immediately. Order of operations on a fresh install:- Run a manual scan (
./scripts/phpstan-scan.sh), fix what surfaces inlogs/phpstan/owned-latest.json, repeat untillogs/phpstan/owned_errors_count.txtreads0— this makes the count trigger silent and the XML file fresh enough to silence the freshness trigger. - Then link the template to your zabbix-agent host. If you can't get the count to zero on day one, link the template anyway and silence the count trigger per-host via the Zabbix UI (Hosts → host → Triggers → check, mass-update) or temporarily flip it to DISABLED at template level via
api.trigger.update(triggerid=<id>, status=1). Re-imports of the YAML will re-enable it. - The failure-marker trigger only fires when the script writes a sentinel — it is silent on a healthy fresh install regardless of order.
- Run a manual scan (
The single weekly scan takes ~2–4 minutes on a Cascade Lake 8-vCPU host (cold cache; warm re-run is ~1 minute). flock prevents the cron run from colliding with a manual invocation.
These are the relevant Bitrix config files that connect the CMS to the dockerised services (memcached for sessions/cache, MySQL via socket, cron agents). Documentation: sessions 1 2 (ru 1, 2), cache (ru)
bitrix/php_interface/dbconn.php
// Enable cron-based agent execution
define('BX_CRONTAB_SUPPORT', true);
// Database connection (legacy, also configured in .settings.php)
$DBType = "mysql";
$DBHost = "localhost";
$DBName = "<DBNAME>";
$DBLogin = "<DBUSER>";
$DBPassword = "<DBPASSWORD>";
// Temporary files directory
define('BX_TEMPORARY_FILES_DIRECTORY', '/tmp');
// Standard Bitrix configuration
define("BX_UTF", true);
define("BX_FILE_PERMISSIONS", 0644);
define("BX_DIR_PERMISSIONS", 0755);
@umask(~(BX_FILE_PERMISSIONS|BX_DIR_PERMISSIONS)&0777);
define("BX_DISABLE_INDEX_PAGE", true);bitrix/.settings.php
'session' => array (
'value' =>
array (
'mode' => 'separated',
'lifetime' => 14400,
'handlers' =>
array (
'kernel' => 'encrypted_cookies',
'general' =>
array (
'type' => 'memcache',
'host' => 'memcached-sessions',
'port' => '11211',
),
),
),
'readonly' => true,
),
'connections' =>
array (
'value' =>
array (
'default' =>
array (
'className' => '\\Bitrix\\Main\\DB\\MysqliConnection',
'host' => 'localhost',
'database' => '<DBNAME>',
'login' => '<DBUSER>',
'password' => '<DBPASSWORD>',
'options' => 3,
),
),
'readonly' => true,
),bitrix/.settings_extra.php
<?php
return array(
'cache' => array(
'value' => array(
// For PHP 8.0+ use memcached instead of deprecated memcache.
// The php-memcached extension is actively maintained, works with libmemcached
// and provides better performance on modern PHP versions.
'type' => 'memcached',
'memcached' => array(
'host' => 'memcached',
'port' => '11211',
),
// The igbinary serializer reduces cache size by ~50% compared to
// the standard PHP serializer and is faster at deserialization.
// Value 2 = Memcached::SERIALIZER_IGBINARY
// Requires php-igbinary extension to be installed
'serializer' => 2,
// Lock mode (use_lock) prevents simultaneous cache regeneration
// by multiple processes. Under high load, only one process
// generates cache, others receive stale data.
// Requires Bitrix main module version 24.0.0 or higher.
// More info: https://dev.1c-bitrix.ru/learning/course/?COURSE_ID=43&LESSON_ID=3485
'use_lock' => true,
'sid' => $_SERVER["DOCUMENT_ROOT"]."#01"
),
),
);
?>-
Clone the repository:
git clone https://github.com/paskal/bitrix.infra.git cd bitrix.infra -
Create environment files: Copy the example files in
private/environment/and fill in your values:for f in private/environment/*.env.example; do cp "$f" "${f%.example}"; done
Edit each
.envfile — the examples contain comments explaining every variable. At minimum you needmysql.env; the others are for optional services (FTP, monitoring, certificates, webhooks).Optionally copy
.env.exampleto.envto override ports or other compose variables (e.g. if port 80 is taken on the host):cp .env.example .env
-
Set file permissions and create required directories: MySQL uses UID/GID 1001, PHP and Nginx use UID/GID 1000. Run the provided script — it also creates all directories that docker file-mounts need:
sudo ./scripts/fix-rights.sh
-
Start the services:
docker-compose up -d
Pre-built images are pulled from GHCR automatically. You only need
--buildif you've modified the Dockerfiles locally. To enable optional services, see Managing Optional Services with Profiles.Note: bare
docker compose up -dstarts the core stack (nginx, php, php-cron, mysql, both memcached). Optional services (adminer,updater,certbot,zabbix-agent,ftp) are behind profiles — enable them withCOMPOSE_PROFILESor--profilewhen needed.
For information about maintenance and utility scripts, see scripts/README.md.
A fresh clone boots a working Bitrix installer at http://localhost without changing any tracked file (only the documented setup commands below):
- Clone and create env files (steps 1–2 above).
sudo ./scripts/fix-rights.sh— on Linux hosts thechowncalls matter (container UIDs 1000/1001); on macOS Docker Desktop (VirtioFS) only the directory creation does, and the script run withoutsudocreates the directories and cleanly skips the ownership fixes.docker compose up -d- Download the Bitrix trial package (~313 MB) and extract it into
web/prod/:Alternatively, placecurl -L https://www.1c-bitrix.ru/download/start_encode.tar.gz | tar -xz -C web/prod/bitrixsetup.phpthere for a minimal bootstrap. - Open
http://localhost(orhttp://localhost:${HTTP_PORT}if you changed the port in.env). - In the Bitrix wizard, use
localhostas the database host (MySQL communicates via Unix socket), database name frommysql.env(MYSQL_DATABASE), and the user credentials from the same file.
Local overrides without touching tracked files:
docker-compose.override.ymlis gitignored, so laptop-specific tweaks belong in your own override next todocker-compose.ymlrather than in edits to tracked configs:services: mysql: volumes: - ./private/my-local.cnf:/etc/my.cnf.d/zz-local.cnf:ro # e.g. innodb_buffer_pool_size = 512M php: volumes: - ./private/php-local.ini:/etc/php/8.4/fpm/conf.d/99-local.ini:roTypical
private/php-local.inifor a demo:session.cookie_secure = Off(Chromium treatslocalhostas a secure context, but Firefox and curl-driven wizard runs discard the Secure session cookie over plain HTTP and silently freeze on the licence step) andopcache.jit = disable(PHP 8.4 JIT has been observed to segfault php-fpm workers on arm64 hosts, surfacing as 502s after the install). The shippedconfig/mysql/my.cnfis sized for a dedicated server (4 GB buffer pool) — shrink it inmy-local.cnffor an 8 GB Docker Desktop VM.
Production identity (TLS certificates, site vhosts, site-specific cron jobs, CSP headers, etc.) is kept in a separate private repository and attached to this base via a docker-compose.override.yml. The mechanism:
- The private repo is checked out alongside the public one (e.g. at
/web/private/on the server). - A
docker-compose.override.ymlin the private repo is symlinked next todocker-compose.yml. Docker Compose merges them automatically on everydocker composeinvocation. - The override re-adds
container_name:for all services (so scripts that reference containers by name keep working), re-mapsconfig/updater.yamlto the private tasks file, and sets production environment variables. - Site vhosts live in
private/nginx/sites/*.conf— the publicnginx.confalready includes that glob; an empty directory is a no-op on a fresh clone. - A second
/etc/cron.dfile (mounted by the override) carries site-specific host cron jobs (seo-reindex, robots.txt patching, etc.). - Behavioural knobs in the shared nginx files (hotlink protection, the
X-Frame-Optionsvalue, admin-pageframe-ancestors/CORS) are driven by maps inconfig/nginx/conf.d/overlay-maps.conf; the overlay extends them by dropping*.mapfiles intoprivate/nginx/— see the comments in that file for the expected entries.
Security note: a fresh public clone serves no
Content-Security-Policy— the CSP includes are globs that match nothing until an overlay suppliesprivate/nginx/bitrix_csp_headers.conf/static_csp_headers.conf(deliberate: a copy-pasted CSP is worse than none). Before pointing a real domain at this stack, create those files; a minimal starting point isadd_header Content-Security-Policy "default-src 'self';" always;.
To start the full production stack: COMPOSE_PROFILES=certs,dbadmin,monitoring,hooks,ftp docker compose up -d.
Required for ALL ongoing ops, not just startup: once the overlay is active,
nginxdepends_onthe profile-gatedadminer/updater, so everydocker composecommand — includingps,logs,restart,down— fails withservice "nginx" depends on undefined service "updater"unlessCOMPOSE_PROFILESis set. Export it for the session (export COMPOSE_PROFILES=certs,dbadmin,monitoring,hooks,ftp) or prefix each invocation.scripts/disaster-recovery.shsets this default itself.
-
cron/php-cron.cron— cron tasks for the php-cron container. Only the standardcron_events.phpBitrix runner is kept here; site-specific jobs belong in a private overlay cron.d file. Must be owned by root:root with mode 0644 — runscripts/fix-rights.shto fix. -
cron/host.cron— cron tasks for the host machine (backups, image optimisation, JS/CSS minification, DNS token renewal). Site-specific jobs (seo-reindex, robots.txt patching) live in the private overlay. -
mysql/my.cnf— MySQL configuration, applied on top of the package-provided defaults. Sized for a dedicated server (innodb_buffer_pool_size = 4G); shrink for local demos on laptops. -
nginxdirectory — build Dockerfile and shared nginx configuration:nginx.conf— main http block (brotli, gzip, SSL, logging)bitrix.conf— Bitrix-specific location rules (dot-path deny, static serving, FastCGI)fastcgi.conf— FastCGI params including HTTP/3-safeHTTP_HOSTsecurity_headers.conf—X-Content-Type-Options,X-Frame-Options,Referrer-Policystatic-cdn.conf— CDN vhost include (static-only, hotlink protection)bots.conf— bot detection logicconf.d/localhost.conf— HTTP-only demo server on port 80 for a fresh cloneconf.d/host-map.conf—$bitrix_hostmap (preserves:portfor local demos, falls back to$hostfor QUIC)conf.d/upstream.conf,bad_ips.conf,status.conf,useragents.conf— generic infrastructure- Site vhosts live in the private overlay (
private.conf.d/sites/*.conf)
-
phpdirectory contains the build Dockerfiles (Dockerfile.8.3,Dockerfile.8.4,Dockerfile.8.5) and php configuration, applied on top of package-provided one. -
logrotatedirectory contains rotation configs for nginx and PHP logs, applied on the host via symlinks in/etc/logrotate.d/pointing toconfig/logrotate/*. The symlinks are created byscripts/disaster-recovery.sh; on a fresh manual setup, runln -sf /web/config/logrotate/* /etc/logrotate.d/once.
mysql, nginx, php logs. cron and msmtp logs will be written to the php directory.
Maintenance and utility scripts for the infrastructure. See scripts/README.md for detailed documentation of each script.
CLI tools: fgmysql (read-only MySQL access via SSH tunnel) and search-reindex (Yandex/Bing URL reindexing). See scripts/README.md for setup and usage.
Site files in directories web/prod and web/dev.
-
private/environment/— environment files for docker-compose services. Copy.env.examplefiles to.envand fill in your values. Each example file is commented with descriptions of every variable:mysql.env— Percona MySQL credentials (root, application user, read-only agent user)dnsrobocert.env— Yandex Cloud DNS credentials for Let's Encrypt wildcard certificates (certsprofile)zabbix.env— Zabbix Agent 2 configuration (hostname, server address, key restrictions) (monitoringprofile)updater.env— webhook server shared secret (hooksprofile)ftp.env— Pure-FTPD credentials (ftpprofile)seo-reindex.env— Yandex Webmaster OAuth token and quota host for the daily SEO reindex cronbackup.env— S3 bucket, endpoint URL, and domain for backup scripts
-
private/nginx/— nginx include snippets mounted asprivate.conf.d. CSP include globs (bitrix_csp_headers*.conf,static_csp_headers*.conf) match nothing if the directory is empty, so nginx starts on a fresh clone without any stubs. Drop your CSP files here on production. -
private/updater_ssh_key— SSH private key mounted into theupdatercontainer; required by thehooksprofile -
private/letsencrypt/— filled with certificates after thecertbotservice runs -
private/mysql-data/— MySQL data directory (created automatically on first start) -
private/mysqld/— MySQL Unix socket for connections without network -
private/msmtprc— msmtp configuration for PHP mail sending
nginx is preconfigured to serve Bitrix composite snapshots directly, without
PHP (~0.03s) for anonymous, parameter-less GET requests, and to answer
If-Modified-Since with 304. The wiring lives in config/nginx/bitrix.conf
(location / + @bitrix) and config/nginx/conf.d/composite.conf (the
$bx_composite_file map). It ships enabled-by-default but inert: with Bitrix
composite off there are no snapshots, so every request falls through to PHP —
behaviour is identical to a non-composite site. Nothing to configure in nginx.
To turn it on:
- Admin → Settings → Composite site (
/bitrix/admin/composite.php) → enable. Choose file storage (it supports304and is what nginx serves; do not use memcached storage for composite). - Register every domain (incl. subdomains) in the composite domain list.
- Exclude what must stay dynamic (composite is opt-out):
- Non-cacheable templates (admin, account, checkout) and personalised pages:
add an
OnEpiloghandler that calls\Bitrix\Main\Composite\Engine::setEnable(false)for those — keying onSITE_TEMPLATE_IDis robust.setEnable(false)is a no-op while composite is off, so the handler is safe to deploy ahead of enabling. - Strip ad/tracking query params (utm/yclid/gclid/yd_*/ga_*/…) via the composite "Игнорировать параметры URL" setting, so ad clicks collapse to one snapshot instead of one-per-click.
- Non-cacheable templates (admin, account, checkout) and personalised pages:
add an
- Verify: a 2nd anonymous GET of a content page is served by nginx
(
Last-Modified+ETag, noX-Bitrix-Compositeheader), andIf-Modified-Since→304; an ad-param URL, a logged-in cookie, andPOSTall fall through to PHP (X-Bitrix-Composite: Cacheor a full render).
To turn it off: disable composite in the admin. nginx reverts to PHP serving automatically — no nginx change needed.
Deploy note: bitrix.conf is bind-mounted into the nginx container as a
single file, which pins it to the inode present at container start. Editing it
on a running stack via an atomic write (git pull, rsync without
--inplace) replaces the inode, so the container keeps serving the OLD file and
nginx -s reload won't pick up the change. After changing bitrix.conf (or any
single-file-mounted conf) on a running stack, restart the nginx container
(docker restart <nginx> / docker compose up -d nginx), not just reload. A
fresh docker compose up is unaffected — it binds the current inode. Files
under directory mounts (conf.d/, the private overlay) pick up changes without a
restart.
This project uses Docker Compose profiles to manage optional services. This allows you to run only the services you need, saving resources. The core services (nginx, php, php-cron, mysql, memcached, memcached-sessions) will always start.
adminer, zabbix-agent, updater, or ftp, they will no longer start automatically with docker-compose up -d. You must now explicitly enable them using profiles (see examples below) or set the COMPOSE_PROFILES environment variable.
Here are the available profiles and the services they enable:
certs: Enables thecertbotservice (using DNSroboCert technology via theadferrand/dnsrobocertimage) for managing SSL certificates.monitoring: Enableszabbix-agentfor Zabbix monitoring.dbadmin: Enablesadminerfor database administration.hooks: Enablesupdaterfor handling webhooks.ftp: Enablesftpfor FTP access.
Examples:
-
To run only the core services:
docker-compose up -d
-
To run core services plus
adminerandftp:docker-compose --profile dbadmin --profile ftp up -d
-
Alternatively, you can set profiles using the
COMPOSE_PROFILESenvironment variable:COMPOSE_PROFILES=dbadmin,ftp docker-compose up -d
Or export it for the session:
export COMPOSE_PROFILES=dbadmin,ftp docker-compose up -d -
To run all services, including all defined profiles:
COMPOSE_PROFILES=certs,dbadmin,monitoring,hooks,ftp docker compose up -d
Note: the
--profile "*"wildcard syntax is only available in newer compose versions and is not supported on compose 2.6.0. Use the explicitCOMPOSE_PROFILESlist above for compatibility. As mentioned in "Getting Started," this project uses pre-built images. If you've made custom changes to Dockerfiles or need to ensure you have the absolute latest build not yet reflected in the pre-built images, you can add the--buildflag.
This project is configured to support multiple PHP versions. Dockerfiles for 8.3, 8.4 and 8.5 are available in the config/php/ directory.
To switch the PHP version used by the php and php-cron services:
-
Edit
docker-compose.yml:- Locate the
phpservice definition. - Modify the
build.contextandbuild.dockerfileto point to the desired Dockerfile. For example, to switch to PHP 8.5:php: build: context: ./config/php dockerfile: Dockerfile.8.5 # Changed from Dockerfile.8.4 image: ghcr.io/paskal/bitrix-php:8.5 # Update image tag # ... rest of the service definition
- Repeat the same changes for the
php-cronservice definition, ensuring theimagetag is also updated.
- Locate the
-
Rebuild the PHP images: This is a scenario where you would need to build the images:
docker-compose build php php-cron # Or, if you are starting the services at the same time: # docker-compose up -d --build php php-cron # (or simply 'docker-compose up -d --build' if you want to ensure all buildable services are updated)
After building, you can start the services as usual:
docker-compose up -d
For a more dynamic approach to switching PHP versions, you could consider:
- Using an environment variable (e.g.,
PHP_VERSION) in yourdocker-compose.ymlto specify the Dockerfile path and image tag. You would then set this variable in your shell or a.envfile. - Utilizing Docker Compose override files to specify different PHP configurations.
Disaster recovery
You need an Ubuntu host in Yandex Cloud (your folder → Compute Cloud). Production sizing is 100 GB disk / 12 GB RAM / 8 cores; for a rehearsal (standing up a second machine to validate the runbook, DNS switched later) 2 cores / 8 GB is enough.
Provision the VM (yc CLI). This one command is the whole "create a machine" step — it boots the latest non-OS-Login Ubuntu 24.04 and installs your SSH key for user yc-user:
# pick the newest NON-oslogin 24.04 image id (the plain `ubuntu-2404-lts` family
# lives in the `standard-images` folder, not yours — you must look it up by folder):
IMAGE_ID=$(yc compute image list --folder-id standard-images --format json \
| python3 -c 'import sys,json;print([i["id"] for i in json.load(sys.stdin) if i["family"]=="ubuntu-2404-lts"][0])')
yc compute instance create \
--name dr-rehearsal \
--zone ru-central1-a \
--platform standard-v3 \
--cores 2 --memory 8GB --core-fraction 100 \
--create-boot-disk image-id="$IMAGE_ID",size=100GB,type=network-ssd \
--network-interface subnet-id=<SUBNET_ID_IN_THAT_ZONE>,nat-ip-version=ipv4 \
--ssh-key ~/.ssh/id_ed25519.pub
# subnet id: `yc vpc subnet list`. Public IP is printed under network_interfaces → one_to_one_nat → address.Gotchas that otherwise eat ten minutes (all confirmed 2026-06-13):
- Use
--ssh-key <pubkey>, not a cloud-initusers:block. These standard images run cloud-init with the EC2 datasource; auser-datausers:block silently fails (cloud-final.serviceerrors) and no login user is created.--ssh-keysets thessh-keysmetadata and createsyc-userreliably. Log in asyc-user(notubuntu/admin). - SSH with the matching private key.
--ssh-keyonly takes the.pub; make sure its private half is the one your client offers (ssh -i <privkey>or anssh-agentthat has it). A.pubwhose private key you don't hold will hard-fail withPermission denied (publickey)no matter what. image-family=alone resolves against your folder and 404s withImage "ubuntu-2404-lts" not found. Pass the explicitimage-idfromstandard-images(above) or addimage-folder-id=standard-images.- Recreate ≠ instant.
yc compute instance deletethen immediately re-createwith the same--namefails while the old one is stillDELETING. Wait it out:until ! yc compute instance list | grep -q dr-rehearsal; do sleep 8; done.
Then SSH in (as yc-user) and run the restore — it is safe to run multiple times:
# preparation for backup restoration
sudo mkdir -p /web
sudo chown "$USER":"$(id -g -n)" /web
sudo apt-get update >/dev/null
sudo apt-get -y install git >/dev/null
git clone https://github.com/paskal/bitrix.infra.git /web
cd /web
# backup restoration, then follow the script's instructions
sudo ./scripts/disaster-recovery.shRehearsal safety (when the new box must NOT disturb live prod — DNS still points at prod):
- The script's
start_servicesruns baredocker compose up -d; with the restored production overlay (nginxdepends_onadminer+updater) modern compose errors unless profiles are set. Bring the stack up withoutcertsandmonitoring:COMPOSE_PROFILES=dbadmin,hooks,ftp docker compose up -d. Omittingcertsmeans no Let's Encrypt DNS-01 challenge writes to the live DNS zone; omittingmonitoringavoids a duplicateZBX_HOSTNAMEcolliding with prod in Zabbix. The real prod TLS cert is restored fromprivate/letsencrypt/, so HTTPS still serves — verify withcurl -k --resolve favor-group.ru:443:<new-ip> https://favor-group.ru/. - Do not let the host cron fire on the rehearsal box.
create_host_cronjob_if_not_existinstalls/etc/cron.d/bitrix_infra, whose jobs push to the same S3 backup paths as prod (file-backup.sh,mysql-dump.sh) and submit SEO reindex requests. Remove it right after the script:sudo rm -f /etc/cron.d/bitrix_infra /etc/cron.d/bitrix_site. Likewisedocker compose stop php-cronto keep the in-container site cron (exports, price updates) from running against external services twice.
Recovery of files
Presume you have a machine with problems, and you want to roll back the changes:
# restore to directory /web/prod2
# -t 2D means restore from the backup made 2 days
# last argument /web/web/prod2 is the directory to restore to, we're not restoring to the original dir
# so that you can rename it first and then rename this directory to prod
sudo HOME="/home/$(logname)" duplicity -t 2D \
--no-encryption \
--s3-endpoint-url https://storage.yandexcloud.net \
--log-file /web/logs/duplicity.log \
--archive-dir /root/.cache/duplicity \
--file-to-restore web/prod "boto3+s3://favor-group-backup/duplicity_web_favor-group" /web/web/prod2Dev site renewal from backup
The renew-dev.sh script can recreate the dev site either from current production or from an existing backup.
From current production (default):
sudo ./scripts/renew-dev.shFrom a specific backup date:
sudo ./scripts/renew-dev.sh --dateWhen using --date, the script will:
- List available backup dates from
/web/backup/ - Prompt you to select a date (format: YYYY-MM-DD)
- List available backup files for that date
- Prompt you to select a specific backup file
- Restore the database from that backup instead of creating a new dump
This is useful for:
- Testing changes against historical data
- Reverting problematic database changes by comparing with old backups
- Debugging issues that appeared after a specific date
Example workflow for reverting SEO changes:
# 1. Restore dev from a backup before the problematic change
sudo ./scripts/renew-dev.sh --date
# Select 2025-10-31 (or earlier backup)
# 2. Use the LLM revert tool at https://favor-group.ru/local/tools/seo_llm_revert.php
# Enter 'dev_favor_group_ru' as the backup database
# Compare and selectively revert changesCleaning (mem)cache
There are two memcached instances in use, one for site cache and another for sessions. Here are the commands to clean them completely:
# to flush site cache
echo "flush_all" | docker exec -i memcached /usr/bin/nc 127.0.0.1 11211
# to flush all user sessions
echo "flush_all" | docker exec -i memcached-sessions /usr/bin/nc 127.0.0.1 11211Here is the complete list of commands you can send to it.
Manual certificate renewal
DNS verification of a wildcard certificate is set up automatically through Yandex Cloud DNS via the certbot service (which uses DNSroboCert technology via the adferrand/dnsrobocert image).
To renew the certificate manually, if needed, you can run the following command which uses the certbot command available within the certbot service's container (which runs adferrand/dnsrobocert):
# Note: The service is certbot, and the command inside is also certbot
docker-compose run --rm --entrypoint "\
certbot certonly \
--email email@example.com \
-d example.com -d *.example.com \
--agree-tos \
--manual \
--preferred-challenges dns" certbotTo add required TXT entries, head to DNS entries page of your provider (Yandex Cloud).
The certbot service is configured to handle renewals automatically.
