Спидометр Google PageSpeed на отметке 100 баллов 100 PageSpeed Insights 38 → 100 До: 38 баллов После: 100 баллов docker compose up -d brotli on; http2 on; LCP 0.8s TTFB 110ms CLS 0
Сайт юрфирмы 35 РМ: с 38 до 100 баллов PageSpeed за рабочий день
· 18 мин чтения · Семёнов Е.С., руководитель ITfresh

Сайт клиента: 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. План был такой:

Утренний блок: подготовка

Дневной блок: Docker и nginx

Вечерний блок: переключение и контроль

В среднем такой день стоит у нас 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 — тот переоткрывает сертификаты без рестарта. Down­time — ноль миллисекунд.

Картинки и шрифты: 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). Контрольные точки:

Через две недели Яндекс начал поднимать сайт по запросам «юрист по корпоративным спорам Москва» с 18-й позиции на 7-ю. По двум коммерческим запросам сайт встал на первую страницу. Это не магия PageSpeed напрямую — это эффект от того, что Яндекс перестал считать сайт «медленным» и снял дисконт ранжирования.

Что мы делаем потом — мониторинг и регламент

Сайт сдан. Но проект не закончен. Дальше я ставлю минимальный мониторинг:

Один раз в квартал я проверяю 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