Сайт клиента: 100 баллов PageSpeed на Docker + nginx + SSL за 1 день
К нам обратилась юрфирма на 35 рабочих мест из ЦАО — их сайт-визитка показывал 38 баллов в Google PageSpeed Insights, картинки грузились по 4 секунды, а маркетолог жаловалась, что Яндекс перестал поднимать сайт в выдаче. К концу одного рабочего дня инженера сайт показывал 100 баллов на десктопе и 99 на мобильном. Ниже — полный технический разбор: как устроен наш референсный nginx-конфиг в Docker, как мы выдаём Let's Encrypt без даунтайма и почему один правильно собранный контейнер на голой Ubuntu обыгрывает любой shared-хостинг.
Откуда взялись 38 баллов и почему сайт «тормозил»
Маркетолог клиента попросила «посмотреть сайт, кажется, что-то не так». Я открыл PageSpeed Insights и увидел 38 на десктопе, 22 на мобильном. Largest Contentful Paint — 6.4 секунды. Time to First Byte — 1.8 секунды. Первое, что бросилось в глаза, — сайт жил на shared-хостинге у одного из тех провайдеров, где у вас 2 ГБ места, общий пул PHP-FPM на 200 клиентов и Apache 2.4 без HTTP/2. Сертификат был, но через два редиректа — http → https → www.https. Каждый редирект добавлял 200 миллисекунд.
Дальше — карта проблем. Картинки в формате PNG по 1.6 МБ каждая. Фавикон в 32×32 раздавался без кэш-заголовков. CSS не минифицирован. Шрифты тянулись с Google Fonts с полным набором кириллицы и латиницы. Один JS-файл на 240 КБ блокировал рендер. Ну и финальный аккорд — gzip отключён на стороне хостинга, потому что «нагрузка на CPU». Брат Брат Бротли там даже не ночевал.
Я в таких ситуациях делю работу на две части: серверная часть (то, что выкачивается за день и даёт сразу 30-40 баллов) и контентная часть (картинки, шрифты, скрипты — это уже работа верстальщика на 2-3 дня). Сегодня — серверная часть. На неё команда тратит ровно один человеко-день, и за это клиент получает измеримый рост в выдаче.
Один день — как мы планируем работу
В 10:00 я приехал в офис клиента в районе Чистых прудов. К 18:00 сайт уже жил на новой VM. План был такой:
Утренний блок: подготовка
- 10:00–10:30 — забрал доступы к домену (учётка регистратора), скачал контент сайта целиком через wget;
- 10:30–11:00 — арендовал VM на Selectel: 2 vCPU, 4 GB RAM, 40 GB NVMe, 690 ₽/месяц, OS Ubuntu 24.04 LTS;
- 11:00–11:30 — ssh-ключи, отключение пароля для root, fail2ban, ufw, автоматические unattended-upgrades.
Дневной блок: Docker и nginx
- 11:30–13:00 — Docker Engine, docker-compose, тестовая сборка nginx с brotli;
- 13:00–14:00 — nginx-конфиг с HTTP/2, OCSP stapling, кэш-заголовками, gzip + brotli;
- 14:00–15:00 — Let's Encrypt через webroot, первый сертификат, автообновление;
- 15:00–16:00 — миграция статики, проверка путей, фавиконы, robots.txt, sitemap.xml.
Вечерний блок: переключение и контроль
- 16:00–16:30 — A-запись домена на новый IP, TTL минимум, ждём;
- 16:30–17:30 — замеры PageSpeed, SSL Labs, ssllabs.com получает A+;
- 17:30–18:00 — сдача проекта: показал маркетологу замеры, выслал инструкцию по работе с Docker.
В среднем такой день стоит у нас 35–50 тысяч рублей. Дальше — техническая часть, которая повторяется у любого клиента из этой категории.
Базовая VM: что должно быть на сервере до Docker
Раз я ставлю Docker на Ubuntu 24.04, базовый набор работ — стандартный. Никакого Apache, никакого ISPmanager, никакого Plesk. Всё минимально, всё руками, всё под контролем:
# Свежая система
apt update && apt full-upgrade -y
# Базовые утилиты
apt install -y curl wget git ufw fail2ban htop vim ncdu \
unattended-upgrades apt-listchanges
# Часовой пояс — Москва
timedatectl set-timezone Europe/Moscow
# Файрвол: только 22, 80, 443
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
# fail2ban — защита от перебора SSH
systemctl enable --now fail2ban
# Автообновления безопасности
dpkg-reconfigure -plow unattended-upgrades
Дальше — Docker. Я ставлю не из репозитория Ubuntu, а с официального docker.com — там всегда свежая версия и долгая поддержка:
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt update
apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
# Проверка
docker run --rm hello-world
На этом этапе у меня чистая VM с Docker, файрволом, SSH по ключу и автообновлениями. Время — 11:30. Дальше переходим к самому сайту.
Dockerfile: nginx с brotli из исходников
Стандартный nginx из docker hub не умеет brotli. А brotli даёт на типичном HTML/CSS/JS на 15-25% лучшую компрессию относительно gzip. Поэтому я собираю свой образ. Вот рабочий Dockerfile, который мы используем у клиентов:
FROM nginx:1.27-alpine AS builder
ARG NGINX_VERSION=1.27.4
ARG BROTLI_VERSION=v1.0.0rc
RUN apk add --no-cache \
git build-base pcre-dev zlib-dev openssl-dev linux-headers \
&& cd /tmp \
&& wget https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz \
&& tar -xf nginx-${NGINX_VERSION}.tar.gz \
&& git clone --recursive https://github.com/google/ngx_brotli.git \
&& cd nginx-${NGINX_VERSION} \
&& ./configure \
--with-compat \
--add-dynamic-module=/tmp/ngx_brotli \
&& make modules \
&& mkdir -p /modules \
&& cp objs/*.so /modules/
FROM nginx:1.27-alpine
COPY --from=builder /modules/ngx_http_brotli_filter_module.so \
/usr/lib/nginx/modules/
COPY --from=builder /modules/ngx_http_brotli_static_module.so \
/usr/lib/nginx/modules/
# Подкатываем нашу конфигурацию
COPY nginx.conf /etc/nginx/nginx.conf
COPY conf.d/ /etc/nginx/conf.d/
# Без рута — security baseline
RUN chown -R nginx:nginx /var/cache/nginx /var/log/nginx /etc/nginx
USER nginx
EXPOSE 80 443
STOPSIGNAL SIGQUIT
CMD ["nginx", "-g", "daemon off;"]
Сборка проходит за 90 секунд на нашем dev-сервере, итоговый образ весит 38 МБ — это меньше, чем стоковый nginx с тяжёлым OpenSSL. Тегаю его как itfresh/nginx-brotli:1.27.4 и кладу в локальный registry.
nginx.conf: реальный референсный конфиг для статики
Главный файл конфига выглядит так. Я его правлю под каждого клиента, но каркас неизменный пять лет:
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
http {
# Базовые
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 1000;
types_hash_max_size 2048;
server_tokens off;
client_max_body_size 32M;
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Логи
log_format itfresh '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct=$upstream_connect_time';
access_log /var/log/nginx/access.log itfresh buffer=64k flush=5s;
error_log /var/log/nginx/error.log warn;
# Компрессия
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json
application/javascript application/xml+rss
application/atom+xml image/svg+xml;
brotli on;
brotli_comp_level 6;
brotli_static on;
brotli_types text/plain text/css text/xml application/json
application/javascript application/xml+rss
application/atom+xml image/svg+xml;
# SSL общий
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 77.88.8.8 1.1.1.1 valid=300s;
resolver_timeout 5s;
include /etc/nginx/conf.d/*.conf;
}
А вот сайт-специфичный конфиг для домена клиента — отдельный файл в conf.d/zakon-yurist.conf (имя анонимизировано):
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# Только ACME-челлендж и редирект
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name zakon-yurist.ru www.zakon-yurist.ru;
ssl_certificate /etc/letsencrypt/live/zakon-yurist.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/zakon-yurist.ru/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/zakon-yurist.ru/chain.pem;
root /var/www/zakon-yurist;
index index.html;
# Безопасность
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
# Кэш для статики
location ~* \.(?:css|js|woff2|svg|png|jpg|webp|avif|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
# HTML — пусть протухает быстро
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
}
location / {
try_files $uri $uri/ /index.html;
}
location = /robots.txt { access_log off; log_not_found off; }
location = /favicon.ico { access_log off; log_not_found off; }
}
Из тонкостей: я не использую ssl_protocols TLSv1.0 TLSv1.1 даже для совместимости со старыми клиентами. Эти протоколы выкинуты браузерами в 2020 году, и единственные, кому они нужны, — это внутренние корпоративные системы, которым в любом случае не место в публичном вебе. На SSL Labs такая конфигурация даёт A+ без оговорок.
docker-compose: nginx + certbot одной командой
Финальный compose-файл выглядит так. Он живёт в /opt/sites/zakon-yurist/docker-compose.yml:
services:
nginx:
image: itfresh/nginx-brotli:1.27.4
container_name: zakon-nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./conf.d:/etc/nginx/conf.d:ro
- ./www:/var/www/zakon-yurist:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
- ./logs:/var/log/nginx
networks:
- sitenet
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/healthz"]
interval: 30s
timeout: 5s
retries: 3
certbot:
image: certbot/certbot:latest
container_name: zakon-certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot --deploy-hook \"docker kill -s HUP zakon-nginx\"; sleep 12h & wait $${!}; done;'"
networks:
- sitenet
networks:
sitenet:
driver: bridge
Запуск первого сертификата — отдельным разовым прогоном:
docker run --rm \
-v $(pwd)/certbot/conf:/etc/letsencrypt \
-v $(pwd)/certbot/www:/var/www/certbot \
certbot/certbot certonly \
--webroot -w /var/www/certbot \
--email admin@zakon-yurist.ru \
--agree-tos --no-eff-email \
-d zakon-yurist.ru -d www.zakon-yurist.ru
docker compose up -d
После этого certbot-контейнер раз в 12 часов проверяет, не пора ли продлить сертификат, и при обновлении посылает HUP в nginx — тот переоткрывает сертификаты без рестарта. Downtime — ноль миллисекунд.
Картинки и шрифты: 50% PageSpeed-баллов лежат здесь
Серверная часть даёт стартовые 60-70 баллов. Чтобы добить до 100, надо отдать клиенту ровно столько мегабайт, сколько он реально потратит на просмотр главной страницы. У юрфирмы из ЦАО на главной было 14 фотографий по 1.6 МБ каждая в PNG. Я переконвертировал их через cwebp:
# Один проход по всему каталогу: PNG/JPG → WebP с качеством 82
find /var/www/zakon-yurist/img -type f \( -iname "*.png" -o -iname "*.jpg" \) \
| while read f; do
cwebp -q 82 -m 6 "$f" -o "${f%.*}.webp"
done
# Дополнительно — AVIF для современных браузеров (Safari 16.4+, Chrome 85+)
find /var/www/zakon-yurist/img -type f -iname "*.webp" \
| while read f; do
avifenc -s 4 -j 4 -q 60 "$f" "${f%.*}.avif"
done
Размер каталога картинок упал с 23 МБ до 1.8 МБ — то есть в 12 раз. Затем верстальщик перевёл все теги <img> на <picture> с тремя источниками:
<picture>
<source srcset="/img/team-1.avif" type="image/avif">
<source srcset="/img/team-1.webp" type="image/webp">
<img src="/img/team-1.jpg" alt="Команда юристов"
width="600" height="400" loading="lazy" decoding="async">
</picture>
Шрифты Google Fonts я заменяю на self-hosted woff2 — это снимает 200-300 миллисекунд DNS+TLS handshake к fonts.googleapis.com. Заодно избавляемся от зависимости от внешнего CDN, который иногда «глючит» под санкционным трафиком.
Замеры до и после: что показал Lighthouse
Я делал замеры в 10:00 (исходный сайт на shared) и в 17:30 (новая VM). Контрольные точки:
- Performance (десктоп): 38 → 100;
- Performance (мобайл): 22 → 99;
- Largest Contentful Paint: 6.4 с → 0.8 с;
- First Contentful Paint: 2.1 с → 0.3 с;
- Time to First Byte: 1.8 с → 0.11 с;
- Total Blocking Time: 480 мс → 0 мс;
- Cumulative Layout Shift: 0.18 → 0.00;
- Размер главной страницы: 4.2 МБ → 240 КБ;
- SSL Labs: B → A+;
- securityheaders.com: D → A.
Через две недели Яндекс начал поднимать сайт по запросам «юрист по корпоративным спорам Москва» с 18-й позиции на 7-ю. По двум коммерческим запросам сайт встал на первую страницу. Это не магия PageSpeed напрямую — это эффект от того, что Яндекс перестал считать сайт «медленным» и снял дисконт ранжирования.
Что мы делаем потом — мониторинг и регламент
Сайт сдан. Но проект не закончен. Дальше я ставлю минимальный мониторинг:
- UptimeRobot или внутренний Uptime Kuma — пинг каждые 60 секунд, алерт в Telegram @ITfresh_Boss при 2 подряд фейлах;
- SSL-monitoring через cron-задачу:
openssl s_client -connect zakon-yurist.ru:443 -servername zakon-yurist.ru— если до истечения меньше 14 дней, мне в Telegram прилетает напоминание; - nginx access_log парсится через GoAccess раз в неделю и собирается в HTML-отчёт — клиент видит, кто сколько ходил;
- docker logs пишутся через journald-driver и ротируются автоматически, чтобы не выжрать диск;
- Бэкап содержимого
/opt/sitesраз в сутки на наш FTP-хранилище ITfresh_FTP — это HP DL380 Gen8 с RAID60 и дедупликацией ReFS, держит копии 60 дней.
Один раз в квартал я проверяю PageSpeed повторно: иногда контент-менеджер заказчика добавляет картинку в JPG на 3 МБ — и баллы скатываются в 80-е. На этот случай у меня есть бот в Telegram, который раз в неделю гоняет lighthouse-cli и шлёт PDF-отчёт.
Контр-нарратив: где 100 баллов вредят бизнесу
Скажу непопулярное. Гонка за 100 баллов PageSpeed бывает вредной. Я видел проекты, где маркетолог требовал 100/100, и ради этого ему отключали Яндекс.Метрику, GTM, чат на сайте и видеоблок главного менеджера. Поисковик видит цифру 100, а конверсия проседает на треть.
Реальная цель — не цифра. Реальная цель — чтобы LCP был меньше 2.5 секунд, CLS меньше 0.1, INP меньше 200 мс. Это пороги Core Web Vitals, и они напрямую завязаны на ранжирование. Всё, что выше — приятный бонус, но никак не самоцель. На сайтах с активными формами, чатами, аналитикой и видеоконсультациями нормальный потолок — 90-95 баллов на десктопе и 80-85 на мобиле. Это и достаточно, и честно.
FAQ: что чаще всего спрашивают клиенты
Реально ли получить 100 баллов PageSpeed на корпоративном сайте?
Да, но только если сайт не на тяжёлой CMS с десятком неотключаемых плагинов. Для статики, лендингов, сайтов на Grav или Hugo — 100 баллов берётся на Docker + nginx за один день. Для WordPress с WooCommerce реальный потолок 90-95, и за оставшиеся 5 баллов биться обычно невыгодно для бизнеса.
Зачем заворачивать nginx в Docker, если можно поставить пакетом?
Чтобы конфиг + сертификат + контент жили одним атомарным юнитом. Накатили обновление через docker compose pull && up -d — упало — откатили на предыдущий тег за 30 секунд. Пакет с apt такой возможности не даёт, и в случае проблем приходится рыться в системных директориях. Плюс на одной VM свободно живут 5-10 контейнерных сайтов разных клиентов без конфликтов версий.
Сколько стоит у ITfresh такая разовая оптимизация?
Для статического сайта или Grav/Hugo — 35-50 тысяч рублей за день работы инженера: настройка Docker, nginx, SSL, brotli, кэширования, аудит изображений, замеры до и после, документация. Для WordPress или Bitrix — 70-120 тысяч, потому что там добавляется работа с плагинами кэширования, образами медиабиблиотеки и базой.
Не сломается ли сайт после Let's Encrypt auto-renew?
Не сломается, если certbot настроен через webroot или DNS-01, а nginx умеет перечитать сертификат без рестарта. Мы у клиентов делаем cron-задачу с certbot renew --deploy-hook, которая шлёт SIGHUP в контейнер nginx — ноль downtime. Дополнительно через мониторинг проверяем, что до истечения сертификата осталось >14 дней — если меньше, прилетает алерт мне в Telegram.
Что важнее для PageSpeed — сервер или код сайта?
Код сайта важнее раза в три. На плохом сервере хороший код выдаст 80 баллов, на хорошем сервере плохой код — 50. Но связка из правильного nginx + brotli + HTTP/2 + кэш-заголовков + SSL без лишних редиректов даёт стартовые 20-30 баллов сверху относительно дефолтной установки apache из репозитория. Плюс качественная картина в Lighthouse — Largest Contentful Paint и Time to First Byte падают ровно за счёт сервера.
Итог
Один день инженера, одна VM на Selectel за 690 ₽ в месяц, один контейнер nginx с brotli и certbot — и сайт юрфирмы получил 100 баллов PageSpeed, A+ на SSL Labs и +11 позиций в Яндексе за две недели. Конфиги выше — рабочие, я их разворачивал на 30+ проектах в 2024-2026 годах. Если у вас сайт показывает меньше 70 баллов и вы теряете трафик — напишите, посмотрю и пришлю план работ в течение рабочего дня.
Похожая задача в вашей компании?
Расскажите, что у вас сейчас — пришлю план работ и оценку в течение рабочего дня.
Написать в Telegram или +7 903 729-62-41
Семёнов Е.С., руководитель ITfresh