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
- a second instance of the php image running cron (
php-cron) for Bitrix agents and site jobs, with the same settings as the web PHP
Every core service and adminer carries a Docker health check: nginx fetches its status vhost, php opens the FastCGI port, php-cron looks for the cron daemon, mysql pings over the socket, memcached answers stats. docker compose ps shows the state, and the stock "Docker by Zabbix agent 2" template alerts on an unhealthy container.
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 requested lifetime of one year; browser privacy rules can still shorten retention.
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.
- 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 (times are host time; the production host runs UTC):
- 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/imagesandweb/prod/upload. Uses a SQLite database to track already-processed files and avoid redundant work - Upload deduplication — runs monthly (1st, 02:20) via
scripts/dedup-upload.sh, replacing byte-identical files underweb/prod/uploadwith hard links (hardlink -t -Xfrom util-linux: owner, mode and extended attributes must match, modification time is ignored). Module scratch, exchange and log directories are excluded, a lock keeps runs apart, and the fullhardlinkoutput with its exit status goes tologs/dedup-upload.log../scripts/dedup-upload.sh --dry-runshows what a run would save. Hard links share later in-place writes between the merged copies, so review the excluded directories for your own modules before enabling the cron; needs util-linuxhardlink(with--respect-xattrs) andflockon the host - Backups —
scripts/file-backup.shdaily (01:25) with duplicity,scripts/mysql-dump.shtwice a day (01:05 and 15:05), both to Yandex Object Storage under the host name. The file backup runs without encryption and includesprivate/environment/*.env, so the bucket has to stay private; the dump takes the database name fromDOMAINinbackup.env, not fromMYSQL_DATABASE - Housekeeping —
scripts/fix-rights.shdaily (01:00) restores container ownership,scripts/update-dns-token.shrefreshes the Yandex Cloud DNS token three times a day for thecertsprofile,docker system prune -fdaily (03:10) removes stopped containers, unused networks, dangling images and build cache host-wide, mostly what the hourly minify runs leave behind - PHPStan — weekly (Monday 04:30) via
scripts/phpstan-scan.sh, see the monitoring section - Log rotation — configured in
config/logrotate/: nginx (weekly for the production access log at 100 MB minimum, monthly for others, reopened vianginx -s reopen), PHP (monthly for error, cron and msmtp logs, php-fpm signalled withUSR1), MySQL error and slow logs (monthly,mysqladmin flush-logs), duplicity, Bitrix cron logs underlocal/cron/, module logs underupload/(bitrix-upload, the S-Production log daily with 7 copies) and the FTP logs (daily, 365 copies)
Production public-repository webhook pulls run through scripts/pull-public.sh. The wrapper validates
the admin user, repository, branch and origin; temporarily makes only config/cron and
config/logrotate writable; restores their root ownership and 0644 file modes on every exit path;
and rebinds only file-mounted cron or nginx configuration whose inode changed. The initial rollout
must stage the wrapper outside the worktree before switching the private updater task:
cd /web
git fetch origin master
git show origin/master:scripts/pull-public.sh >/tmp/pull-public.sh
chmod 0700 /tmp/pull-public.sh
sh /tmp/pull-public.sh
rm -f /tmp/pull-public.sh
cd /web/private
sh /web/private/scripts/pull-private.sh
docker restart updater
docker exec updater grep -F '/web/scripts/pull-public.sh' /etc/updater.ymlBitrix 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 host time"] -->|"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, no PHAR body is downloaded 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, owned by the UID/GID of thephpcontainer'swww-datauser, 1000:1000 in this repo's images:mkdir -p logs/phpstan && sudo chown 1000:1000 logs/phpstan && sudo chmod 2775 logs/phpstan. With another image, checkdocker exec php id www-dataonce it runs and adjust. 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. The example'sexcludePathsholds a commented placeholder: list the third-party module trees that ship insidephp_interface/include/on your codebase. - 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 host time (UTC on the production host). 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 are enabled by default, so re-imports stay armed without an extra step. The flip-side is that linking the template before the system has scanned once fires the count trigger immediately: the count item falls back to
99999while the file is missing, and the freshness trigger stays silent until the XML file exists, because its item is unsupported before that. 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,
// once the database is utf8mb4, see "Converting the database to utf8mb4"
'charset' => 'utf8mb4',
'utf8mb4' => array('global' => true),
),
),
'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 cp config/zabbix/mysql-session.conf.example private/environment/zabbix-mysql-session.conf
Edit each copied file. At minimum you need
mysql.env; the others are for optional services (FTP, monitoring, certificates, webhooks). For MySQL monitoring,zabbix-mysql-session.confkeeps the monitoring credentials in an Agent 2 named session instead of expanded item keys. Compose refuses to start the monitoring service if this file is absent.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; AGENTS.md has the same steps condensed for coding agents):
- 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), the database namebitrixunless you setMYSQL_DATABASEin the root.env(docker-compose.ymlpasses${MYSQL_DATABASE:-bitrix}to the container, which takes precedence overmysql.env), and the user credentials frommysql.env.
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. - The override mounts a second
/etc/cron.dfile intophp-cronwith site-specific container jobs (exports, sitemap generators). Site-specific host jobs (seo-reindex, robots.txt patching) live inprivate/cron/host-site.cron, whichdisaster-recovery.shlinks as/etc/cron.d/bitrix_site. - 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 site-wide
Content-Security-Policy(the only CSP headers it sends are the sandbox on uploaded SVG andframe-ancestors 'self'on admin pages) — 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 regular production stack: COMPOSE_PROFILES=certs,dbadmin,monitoring,hooks 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,restartanddown, fails withservice "nginx" depends on undefined service "updater"unlessCOMPOSE_PROFILESis set. Export it for the session (export COMPOSE_PROFILES=certs,dbadmin,monitoring,hooks) or prefix each invocation.scripts/disaster-recovery.shsets this default itself. Do not include the manual FTP profile in this default.On the production host this default now lives in
/web/.envasCOMPOSE_PROFILES=certs,dbadmin,hooks,monitoring, so a baredocker composethere resolves all ten services. That file is gitignored, so deploys leave it alone;file-backup.shincludes it anddisaster-recovery.shrestores it with the rest, so on a rebuilt host check that it came back before running any compose command. It was added on 29 Aug 2026 after a compose run without the profiles leftzabbix-agentremoved, which silently took monitoring down and produced eight cascading alerts (agent unavailable, MySQL/Docker/Memcached no-data, and a backup alert that was purely stale data, the backup itself having run normally).
-
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) and shipped defaults that touch custom endpoints: denied module and service paths, fast 404 for scanner probes (dumps, backups, env files, editor leftovers), uploaded HTML served astext/plain, PHP-like files under/upload/answered 404, uploaded SVG sandboxed with CSP, the S-Production log directory answered 404fastcgi.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 logic. Included by the PHP locations only, so it never touches static assets. It does two separate things: setsIS_BOTfrom$bad_agent/$bad_ip/$bad_referer(a hint passed to PHP, the request still runs), and answers403for$denied_ip/$denied_agent(the request never reaches PHP). Both deny maps ship with a few entries (a US VPS range used for SQL-injection fuzzing, Meta's crawlers and LinkupBot, which ignorerobots.txt); other matches in thebad_*maps are flagged without being denied, and unmatched traffic is neither.conf.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/composite.conf— the$bx_composite_filemap for serving composite snapshots without PHPconf.d/metrika-cookies.conf— the Metrika cookie mapsconf.d/overlay-maps.conf— default maps the private overlay extends (hotlink protection,X-Frame-Options, admin-pageframe-ancestors/CORS, static-host CORS origin)conf.d/cid-context-ratelimit.conf—limit_reqzone (5 req/s per IP) for the anonymous pageview-context beacon/local/ajax/t.js; the location that uses it is inbitrix.confconf.d/upstream.conf,bad_ips.conf,status.conf,useragents.conf— generic infrastructure.bad_ips.confholds$bad_ip(flag) and$denied_ip(403);useragents.confholds$bad_agent(flag) and$denied_agent(403). Put an entry in a deny map only when it cannot match a real visitor: a self-identified crawler that ignoresrobots.txtor brings no traffic worth the render cost, or an address range whose every request is an attack. Search crawlers that obeyrobots.txtand send visitors do not belong there, and a denied CIDR also denies any legitimate visitor sharing it.- Site vhosts live in the private overlay:
private/nginx/sites/*.conf, mounted as/etc/nginx/private.conf.d/sites/
-
phpdirectory contains the build Dockerfiles (Dockerfile.8.3,Dockerfile.8.4,Dockerfile.8.5) and php configuration, applied on top of package-provided one.91-disable-functions.inidisablessystem,passthru,shell_exec,popen,pcntl_exec,pcntl_fork,pcntl_signal,pcntl_waitpid,pcntl_wexitstatusanddl;execandproc_openstay for admin tools and acrit.export. -
logrotatedirectory contains the rotation configs listed under Automation, 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. -
zabbixdirectory — thezabbix-agent2image build,mysql-session.conf.examplefor the MySQL named session,ALERT-ROUTING.mdandtemplates/: Zabbix templates for backup freshness, the Docker host, site and subdomain checks, content checks, PHPStan, and thefavor-group-*automation templates, which are site-specific and kept here for versioning -
adminer/mysql-socket.ini— PHP setting that points adminer at the MySQL socket -
updater.yaml— generic example of the webhook task list for theupdaterservice; the private overlay maps its own file over it -
dnsrobocert.yml— DNSroboCert configuration for thecertsprofile (Yandex Cloud DNS challenge)
mysql, nginx, php logs; msmtp writes into the php directory. The Bitrix cron runner logs to web/prod/local/cron/cron_events.log, rotated by config/logrotate/bitrix-cron.
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)zabbix-mysql-session.conf— private MySQL named session for Agent 2; set{$MYSQL.DSN}toLocalMysqland leave{$MYSQL.USER}/{$MYSQL.PASSWORD}empty in Zabbixupdater.env— webhook server shared secret (hooksprofile)ftp.env— Pure-FTPD credentials (ftp-manualprofile)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. The include globs (bitrix_csp_headers*.conf,static_csp_headers*.conf,document_headers*.conf,not-logging*.map,metrika-domain*.map,sites/*.confand the*.mapfiles fromoverlay-maps.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 GET and HEAD requests without a query string from visitors whose
cookies do not contain BITRIX_SM_LOGIN (the remembered-login cookie; adapt the map in
composite.conf if your cookie prefix differs), 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, aBITRIX_SM_LOGINcookie, 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, or docker compose up -d --force-recreate --no-deps nginx; a plain
up -d skips a service whose config and image are unchanged), 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. Webhook pulls through scripts/pull-public.sh test a changed file-mounted
conf in a fresh container and restart nginx themselves.
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-manual: Enables temporary FTP access. This profile is deliberately excluded from regular production operations.
Examples:
-
To run only the core services:
docker compose up -d
-
To start FTP for a temporary access window:
COMPOSE_PROFILES=certs,dbadmin,monitoring,hooks,ftp-manual docker compose up -d ftp
-
Stop and remove FTP at the end of that window:
COMPOSE_PROFILES=certs,dbadmin,monitoring,hooks,ftp-manual docker compose stop ftp COMPOSE_PROFILES=certs,dbadmin,monitoring,hooks,ftp-manual docker compose rm -f ftp
The current container run logs to
/web/logs/ftp/pureftpd.log; the entrypoint appends it to/web/logs/ftp/session-history.logat the next container start, because the image clears the log then. Verbose logging records connections, authentication failures and successes, commands, transfers, and disconnects while redacting passwords. Host logrotate rotates both files daily and retains 365 copies. -
To run all services, including all defined profiles:
COMPOSE_PROFILES=certs,dbadmin,monitoring,hooks,ftp-manual docker compose up -d
The explicit list above enables all profiles. 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.
Dockerfiles for PHP 8.3, 8.4 and 8.5 live in config/php/, and CI builds every one of them as ghcr.io/paskal/bitrix-php:<version>, so switching needs no local build:
- In
docker-compose.yml, change theimagetag of bothphpandphp-cron(for exampleghcr.io/paskal/bitrix-php:8.5) and the/etc/php/8.4/...destination paths of the config binds under both services (90-php.ini,91-disable-functions.ini,zz-pm.conf,xdebug.ini). With the old paths the files land in a directory the new PHP never reads and the settings silently disappear.php-cronhas nobuildblock; onlyphpbuilds locally, so also pointbuild.dockerfileunderphpat the selected version before any local build. docker compose pull php php-cron && docker compose up -d php php-cron. Build locally only after changing a Dockerfile:docker compose build php.
The 8.5 image carries php-memcached only (php-memcache is not packaged for it): a .settings.php session block with 'type' => 'memcache' has to move to memcached before the switch, the cache block already uses it.
The commands below use the production container names (nginx, php, php-cron, mysql, memcached), which the private overlay pins with container_name:; a plain clone has Compose-generated names, so use docker compose exec <service> there.
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 can be run again after an interruption: files are merged, and a database that already holds tables is left alone, so a partly imported database has to be emptied by hand (or dropped and created again, the restore expects it to exist) before the rerun:
# 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 starts the stack itself with
COMPOSE_PROFILES=certs,dbadmin,monitoring,hooksunless the variable is already set, so pass the reduced set into the invocation:sudo env COMPOSE_PROFILES=dbadmin,hooks ./scripts/disaster-recovery.sh. Setting it afterwards is too late,certbotandzabbix-agentwould already be running. 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, andbitrix_sitewhen the overlay is present. The backup jobs (file-backup.sh,mysql-dump.sh) write to S3 under the host name, so with the same bucket and hostname as prod they write into the production backup prefix and can interfere with its backup chain;bitrix_sitesubmits SEO reindex requests. Remove it right after the script:sudo rm -f /etc/cron.d/bitrix_infra /etc/cron.d/bitrix_site.php-cronis a core service and starts with the stack, before the database import; the unmodified script gives it no isolation, sodocker compose stop php-cronright after the script finishes only stops later runs of the in-container site cron (exports, price updates) against external services.
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 --dateThe site-specific values are read from the environment when non-empty, so another site runs the
script by setting what differs. Defaults in brackets: DOMAIN (favor-group.ru), DEV_SUBDOMAIN
(dev; the dev host is <DEV_SUBDOMAIN>.<DOMAIN>), PROD_LOCATION (./web/prod), DEV_LOCATION
(./web/<DEV_SUBDOMAIN>), BACKUP_LOCATION (./backup), CONTAINER_WEB_ROOT (/web) and
DEV_DOCROOT (<CONTAINER_WEB_ROOT>/<DEV_SUBDOMAIN>; it must be DEV_LOCATION as mounted inside
the php container, see the php volumes in docker-compose.yml), PHP_CONTAINER (php),
MYSQL_CONTAINER (mysql), MYSQL_ENV_FILE (./private/environment/mysql.env), MYSQL_DATA_DIR
(./private/mysql-data, the host path of the container's /var/lib/mysql), WEB_UID and WEB_GID
(1000), PROD_DB, DEV_DB and DEV_USER (derived from the domains with dots and dashes as
underscores), DEV_ADMIN_USER_IDS (6 92 1560 1561 when unset, user ids added to the administrators
group on dev; set it empty to add none), DEV_ADMIN_IP_MASK (255.255.0.0) and POST_RENEW_HOOK
(./private/scripts/renew-dev-post.sh). The script refuses to run when the dev tree is the
production tree or nested with it, when the backups sit inside the dev tree, or when the dev
database or user matches the name or login in the production dbconn.php. For example:
sudo DOMAIN=example.com DEV_ADMIN_USER_IDS="1 7" ./scripts/renew-dev.shWhen 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
After either restore path, the script reapplies DEV-only settings. Administrator sessions and
remembered authorisation for group 1 use the DEV_ADMIN_IP_MASK network mask (255.255.0.0 by
default) so Safari Private Relay can rotate an address within the same /16 without ending the session. Production retains the stricter
255.255.255.255 masks. This slightly broadens the DEV cookie replay boundary, but authentication
and the session cookie are still required. A session created before the override may require one
new login.
The dev DB connection files are generated from the production ones with dev credentials and go
live only after the dump is restored, so the dev site has no database access while the dump is
restored and never holds production credentials. Open sessions of the dev user are closed before
the database is recreated (DROP USER leaves them alive, and php-fpm keeps persistent connections),
and the dev database takes the charset and collation of the production one. After the file sync
the script cleans the dev managed and file caches through the Bitrix API and removes the dev host's
composite page directory, because cache entries from the previous copy describe another database;
with a cache backend shared between sites, dev needs its own sid in .settings_extra.php for
this to stay local.
If the POST_RENEW_HOOK script (private/scripts/renew-dev-post.sh by default) exists and is
executable, the script runs it after the DEV database connection and administrator policy have been
restored. This optional hook keeps
site-specific post-clone settings in the private overlay; installations without it are unchanged.
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 changesConverting the database to utf8mb4
Bitrix installs of this age run on utf8 (utf8mb3), which cannot store 4-byte characters: an
emoji in a name or a comment is silently cut off together with everything after it. The
convert-utf8mb4.sh script converts every table of a database in place; it makes no backup of its
own, so run ./scripts/mysql-dump.sh first:
# see what would change: statement count, sizes, the generated ALTER file
sudo ./scripts/convert-utf8mb4.sh --dry-run favor_group_ru
# convert (stop the site and php-cron first; tables are rebuilt with a copy and block writes)
sudo ./scripts/convert-utf8mb4.sh favor_group_ruOne ALTER TABLE per table that needs it is generated from information_schema: every character
column outside the target collation and utf8mb4_bin is rewritten with its current type,
nullability, default and comment, *_bin columns become utf8mb4_bin, everything else takes the
target collation (default utf8mb4_unicode_ci, the same comparison rules as utf8mb3_unicode_ci;
--collation utf8mb4_0900_ai_ci selects the MySQL 8 default that fresh Bitrix installs use). A
plain CONVERT TO CHARACTER SET is not used: it widens TEXT to MEDIUMTEXT and replaces binary
collations, which breaks unique keys such as b_search_stem.STEM. Before and after the run the
script writes a column snapshot and a per-table fingerprint (row count and folded MD5 of every row)
under logs/utf8mb4/ and fails when a definition changed beyond charset and collation or a
fingerprint differs; a table the application wrote to during the run shows up there too, so stop
everything that writes first. The run stops before anything changes on a column that needs
conversion and is generated or invisible, is a VARCHAR longer than 16383 characters, or has a line
break in its default, type or comment; and anywhere in the database on foreign keys over character
columns, backticks, tabs or line breaks in table or column names, views, non-InnoDB tables and
NO_BACKSLASH_ESCAPES in the global sql_mode. A run that stops halfway can be
repeated and picks up the tables still not converted; the retry does not check against the failed
run's snapshots, so read that run's diff files first.
The database alone does not switch the application. In bitrix/.settings.php, inside
connections → default, add:
'charset' => 'utf8mb4',
'utf8mb4' => array('global' => true),and create bitrix/php_interface/after_connect_d7.php naming the collation the tables were converted
to. Bitrix includes this file on every connection by default (ConnectionPool fills in
include_after_connected when the setting is absent), and its install wizard writes the same file
on a fresh install, so this is the standard place for the connection charset:
<?php
$this->queryExecute("SET NAMES 'utf8mb4' COLLATE 'utf8mb4_unicode_ci'");config/mysql/my.cnf ships default_character_set = utf8mb4 for the client and utf8mb4 server
defaults, and the backup, restore and renewal scripts ask for utf8mb4 themselves, so dumps keep
4-byte characters. The file is a file-level bind: after a pull that changed it,
docker restart --timeout 120 mysql (a database interruption) makes the container read it. A pull
converts no existing database; the server defaults apply to databases created without a charset,
and new tables inherit their database's default, which the conversion sets.
Cleaning (mem)cache
There are two memcached instances in use, one for site cache and another for sessions. The site cache is shared by prod and dev, each under its own namespace (sid in .settings_extra.php); flush_all below empties both, renew-dev.sh shows how to clean one site through the Bitrix API. 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.
