Как настроить HTTPS для docker-compose и не заплакать, пошаговая инструкция

Я периодически делаю pet-проекты и захотелось, чтобы в новом pet-проекте было всё красиво, чтобы сайт по https открывался.
Начал рыться в интернете нашёл 1001 статью и видео. Всё хорошо, только они не работают. Начал разбираться сам, результаты своих исследований решил оформить в виде статьи.

Как работает шифрование?

Как настроить HTTPS для docker-compose и не заплакать, пошаговая инструкция
Начнём немного нудной, но необходимой теории. Что же такое HTTPS, SSL и TLS.
SSL - это устаревший протокол безопасности, который использовался для защиты передачи данных между веб-сервером и клиентским браузером, который придумала компания Netscape в 1995 году. Однако с течением времени были выявлены серьезные уязвимости в протоколе SSL, в результате чего он был признан устаревшим и небезопасным.
TLS - это преемник SSL и представляет собой современный протокол безопасности для защиты передачи данных в Интернете.
HTTPS - это протокол передачи гипертекста, который использует TLS или SSL для обеспечения безопасной передачи данных между веб-сервером и клиентским браузером.
Т.е. когда набивает в браузере https://sait.com Вы используете HTTPS протокол передачи данных, который использует SSL или TLS для шифрования и защиты данных при их передаче между клиентом и сервером.
Основой TLS является комбинация симметричной и асимметричной криптографии.
С симметричной криптографией все довольно просто - если у Алисы и Боба есть общий симметричный ключ, то они могут использовать его для шифрования и расшифрования сообщений. Единственной затруднительностью в этой схеме является вопрос об обмене симметричным ключом так, чтобы никто, кроме них самих, не смог узнать его. В реальной жизни обмен информацией может осуществляться устно, но в виртуальном мире, где устройства используют общую сеть для передачи данных, существует риск, что посредник сможет перехватить симметричный ключ.
Для решения указанной проблемы, представили инновационный протокол, получивший название Диффи-Хеллмана. Очень оригинальное название. 🙂 Этот протокол обеспечивает безопасный обмен конфиденциальной информацией через открытые каналы связи, минимизируя риск перехвата. Основные шаги метода, следующие:

  1. Генерация уникальной пары ключей. Один ключ называется открытым (публичным), а другой — закрытым (приватным).
  2. У этих ключей есть удивительное свойство: если данные зашифрованы с использованием публичного ключа, их можно расшифровать только с помощью соответствующего закрытого ключа.
  3. Публичный ключ распространяется среди всех, кто желает взаимодействовать с вами.
  4. Партнер, получивший ваш публичный ключ, придумывает симметричный ключ, шифрует его с использованием вашего публичного ключа и направляет вам зашифрованный ключ.
  5. Поскольку только у вас есть приватный ключ, только вы способны расшифровать полученное сообщение и получить симметричный ключ, который впоследствии используется для шифрования.
    Отлично, проблема с шифрованием разрешена, но у нас остается одна значительная забота - мы не можем с уверенностью утверждать, что ответ, полученный с другого конца, и в самом деле принадлежит серверу, которому мы отправляли запрос. Для предотвращения атак "человек-посередине" была разработана система сертификатов. Суть сертификата заключается в файле, содержащем определенное количество полей в специальном формате. Среди этих полей: публичный ключ, подпись от выдающего сертификат центра, общее имя сервера (common name), название организации (organization), срок действия и т.д. Интересный аспект здесь связан с подписью - она необходима для того, чтобы клиенты могли доверять вашему серверу. Процесс получения сертификата обычно включает в себя следующие шаги:
  6. Локальная генерация приватного ключа.
  7. Локальная генерация запроса на сертификат, содержащего публичный ключ (полученный из приватного), информацию о компании и сервере.
  8. Отправка запроса в удостоверяющий центр (УЦ), который проверяет правильность предоставленных данных, включая проверку того, что имя сервера, указанное в сертификате, принадлежит вам - вас могут попросить добавить запись в DNS или разместить файл на сервере для подтверждения.
  9. Если всё в порядке, удостоверяющий центр генерирует хеш на основе всех данных в сертификате (ключ, информация о компании, сервер) и шифрует этот хеш своим приватным ключом.
  10. Полученная подпись добавляется в сертификат и отправляется вам.
    Благодаря тому, что подпись зашифрована приватным ключом удостоверяющего центра, а публичная его часть доступна всем, любой человек, используя публичный ключ УЦ, может расшифровать подпись и сравнить хеш, указанный УЦ, с хешем, полученным из полей сертификата. Таким образом, мы можем убедиться в следующем:
  11. Сертификат выдан указанным УЦ, иначе мы бы не смогли расшифровать подпись с использованием публичного ключа.
  12. Сертификат не был изменен, иначе хеш в подписи не совпал бы с заново подсчитанным хешем.
    Фактически, это было краткое введение в функционирование сертификатов, симметричного и асимметричного шифрования.

Как настроить HTTPS для docker-compose и не заплакать, пошаговая инструкция

Более подробно как работает криптография можно узнать вот в этом видео Как работает шифрование? С нуля за час

Теперь давайте рассмотрим, какую роль в этом процессе играет сервис letsencrypt, которая написала программу certbot. На самом деле, это довольно просто. 🙂 Letsencrypt представляет собой УЦ, ответственный за подтверждение того, что ваш домен действительно принадлежит вам, а также за подписание вашего сертификата с использованием своего приватного ключа. Процесс выглядит следующим образом:

  1. Вы генерируете приватный ключ и запрос на сертификат для вашего сервера (предположим, для z.example.com).
  2. Отправляете запрос на сертификат на сервер letsencrypt и выбираете метод подтверждения:
    • DNS challenge — letsencrypt предлагает создать специальную DNS-запись в вашем домене (example.com), чтобы подтвердить владение им.
    • HTTP challenge — letsencrypt предлагает создать файл на сервере z.example.com, доступный по http.
    • HTTPS challenge — letsencrypt предлагает создать файл на сервере z.example.com, доступный по https.
  3. Предположим, вы выбрали HTTP challenge. Затем letsencrypt сообщает вам: создайте файл с содержимым 123 и разместите его по адресу z.example.com/.well-known/acme-challenge/321.
  4. Как только вы создали и разместили этот файл, letsencrypt отправляет HTTP-запрос на ваш сервер и проверяет доступность файла.
  5. Если все в порядке с файлом, letsencrypt подписывает ваш запрос на сертификат своим приватным ключом и возвращает вам готовый сертификат.

Шаг 1. Настраиваем статический сайт

Как настроить HTTPS для docker-compose и не заплакать, пошаговая инструкция
Но хватит теории, приступим к практике. Я поставил перед собой простую задачу есть статический веб-сайт на домене http://test.memory-online.ru. Необходимо к нему "прикрутить" https.
Я создал репозиторий на github где выложил необходимые конфиги.
Предположим, Вы уже купили доменное имя и "прикрутили" его к VPS. VPS я взял на Ubuntu 22.04. На VPS уже настроили docker и docker-compose. Я это делал 2 командами:

  1. Ставим докер
curl -o - https://get.docker.com | bash -
  1. docker-compose
/bin/bash -c "$(curl -fsSL https://git.io/JDGfm)"

Доменное имя и VPS необходимо. Т.к. дальше перед тем, как выдавать сертификат проверять будет проверка, а твой ли это домен? Можно ли получить из него нужный файл?
Если нужен https для локальной разработки лучше выпустить самоподписанный сертификат. Браузеру они не понравятся, но разрабатывать будет можно.
На github в папке no-https находятся первоначальные конфиги.
Файл docker-compose.yml максимально простой

version: '3'

services:
  webserver:
    build: ./data/nginx/conf
    restart: always
    ports:
      - "80:80"
    container_name: webserver
    volumes:
      - ./data/nginx/conf/:/etc/nginx/
      - ./data/nginx/www/:/var/www/

Т.е. в папке пользователя /data/nginx/conf/ находятся конфиги nginx, а в папке /data/nginx/www/ файл index.html, который будет отображать браузер. И это конфиги примонтируем в наш docker-контейнер.
Не забываем изменить права на папку ./data
chown -R www-data:www-data ./data А то внутри контейнера к ним не будет доступа.
И файл настройки веб-сервера тоже сложностью не блещет

server {
    listen 80;
    listen [::]:80;

    root /var/www/;
    index index.html;

    server_name test.memory-online.ru;

    location / {
      try_files $uri $uri/ =404;
    }
}

Как и Dockerfile

FROM nginx:1.25.3
RUN apt update && apt install -y  mc
RUN rm /etc/nginx/conf.d/default.conf

Остальные настройки nginx сделаны, чтобы обеспечить безопасность и сжать трафик. Но как правильно настроить nginx для безопасности и сжатия данных, это тема отдельной статьи.
Запускаем проект

docker-compose up -d

Если всё настроено верно ты можно будет открыть сайт http://test.memory-online.ru/ и увидеть строку "Success! http://test.memory-online.ru/"
Я рад, если у Вас всё получилось сразу с первого раза. Поздравляю! Возможно, вам часто везёт в лотерею. 🙂

Заметили строчку RUN apt update && apt install -y mc Это мы устанавливаем файловый менеджер с текстовым интерфейсом Midnight Commander (mc). Чтобы через команду docker exec -it webserver mc можно было удобно "побродить" по docker-контейнеру и проверить примонтировались ли папки, какой конфиг у nginx и т.д.
Далее есть очень хорошая программа lazydocker которая позволяет посмотреть через терминальный UI состояние docker-контейнеров.
Ставиться она просто

curl https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | bash
echo 'export PATH="$PATH:$HOME/.local/bin/"' >> ~/.bashrc

И в командной строке можно написать lazydocker и отследить состояние контейнеров и почитать логи.
Mc и lazydocker используются чисто для диагностики. Лично у меня с первого раза и даже со второго не получилось. Я решил подсчитать коммиты, получилось с 18 раза. 🙂
Если изменили файлы конфига nginx обязательно нужно их применить командой

docker exec webserver nginx -t

Если что-то пошло совсем не так, не срослось, не получилось от слова "совсем", то можно

docker-compose down #остановить проект
docker system prune -a -f #убить всё
docker-compose up -d #запустить ещё раз проект и отсоединиться от него

Шаг 2. Получаем сертификат

Как настроить HTTPS для docker-compose и не заплакать, пошаговая инструкция
Итак, статический сайт заработал. Настала пора получать сертификат.
На github это папка add-certbot
Docker-compose приводим к следующему виду

version: '3'

services:
  webserver:
    build: ./data/nginx/conf
    restart: always
    ports:
      - 80:80
    container_name: webserver
    volumes:
      - ./data/nginx/conf/:/etc/nginx/
      - ./data/nginx/www/:/var/www/
      - ./data/certbot/www/:/var/www/certbot/
      - ./data/certbot/conf/:/etc/letsencrypt/

  certbot:
    image: certbot/certbot
    depends_on:
      - webserver
    container_name: certbot
    volumes:
      - ./data/certbot/www/:/var/www/certbot/
      - ./data/certbot/conf/:/etc/letsencrypt/

Здесь добавляем новый контейнер certbot, который все свои конфиги хранит в папке /data/certbot/conf/, а файлы для подтверждения, что это сайт именно наш будет складывать в папку /data/certbot/www/. А nginx будет подхватывать и использовать. Не забываем эти папки создать и установить на них права
chown -R www-data:www-data ./certbot/

Конфиг nginx test.memory-online.ru.conf будет выглядеть вот так

server {
    listen 80;
    listen [::]:80;

    root /var/www/;
    index index.html;

    server_name test.memory-online.ru;
    server_tokens off;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
      try_files $uri $uri/ =404;
    }
}

В нём мы показываем, если придёт бот и скажет: "Отдай мне файл http://test.memory-online.ru/.well-known/acme-challenge/Dhz_Vh7pA_yHmjv-268NN0P284v3vDph96FT_EHQtsI, тогда я тебе поверю, что веб-сайт твой". Его нужно отправить в папку cerbot, которая примонтирована по адресу /var/www/certbot.
И теперь волнующий момент вводим команду

docker-compose run --rm certbot certonly --webroot --webroot-path=/var/www/certbot/ --email mail@mail.com --agree-tos --no-eff-email -d test.memory-online.ru

Что делает эта команда? Она говорит, чтобы certbot только получил сертификаты(certonly) для проверки подлинности надо использовать HTTP challenge (--webroot), каталог, куда поместить файл (--webroot-path=/var/www/certbot/), email который необходимо использовать для регистрации (--email mail@mail.com), мы соглашаемся со всем, что нам предложат(--agree-tos), но не надо сообщать мой email третьим лицам (--no-eff-email) и сертификат получить для домена test.memory-online.ru (-d test.memory-online.ru).
Все параметры командной строки можно получить через docker-compose run --rm certbot --help
Смотрим, что напишет эта команда или всё хорошо и вот они файлы сертификата или упадёт с ошибкой. Если с ошибкой проверяем пути, права и т.д. Mc в помощь docker exec -it webserver mc.
Но в любом случае внутри каталога /data/certbot/conf ищем файлы cert1.pem chain1.pem fullchain1.pem privkey1.pem У меня они лежат вот здесь ./data/certbot/conf/archive/test.memory-online.ru
Ещё помните о двух криптографах Диффи и Хеллмане, которые придумали протокол названных их именем? Переходим в каталог /data/certbot/conf и набираем команду openssl dhparam -out ./ssl-dhparams.pem 4096.
Долго ждём. Система генерирует простое число, которое используется в этом протоколе.

Шаг 3. Подключаем сертификат

Как настроить HTTPS для docker-compose и не заплакать, пошаговая инструкция
Настал волнительный момент подключаем полученный сертификат к нашему сайту. На github это папка with-https

В docker-compose добавляем одну строчку открыть порт 443.

ports:  
  - 80:80  
  - 443:443

Много изменений в файле test.memory-online.ru.conf

server {  
    listen 80;  
    listen [::]:80;  

    server_name test.memory-online.ru;  
    server_tokens off;  

    if ($host = test.memory-online.ru) {  
        return 301 https://$host$request_uri;  
    }  
}  

server {  
    listen [::]:443 ssl ipv6only=on;  
    listen 443 ssl;  
    http2 on;  

    root /var/www/;  

    index index.html;  
    server_name test.memory-online.ru;  

    location / {  
        try_files $uri $uri/ =404;  
    }  

    ssl_certificate /etc/letsencrypt/live/test.memory-online.ru/fullchain.pem;  
    ssl_certificate_key /etc/letsencrypt/live/test.memory-online.ru/privkey.pem;  

    include config/options-ssl-nginx.conf;      

    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;  

    add_header Strict-Transport-Security "max-age=31536000" always;  

    ssl_trusted_certificate /etc/letsencrypt/live/test.memory-online.ru/chain.pem;  
    ssl_stapling on;  
    ssl_stapling_verify on;  

    location /.well-known/acme-challenge {  
        allow all;  
        root /var/www/certbot;  
    }  
}

Делаем редирект с сайта http на https

    if ($host = test.memory-online.ru) {  
        return 301 https://$host$request_uri;  
    } 

Подключаем новый сервер, который работает по 443 порту, протоколу ssl и http2.

listen [::]:443 ssl ipv6only=on;  
listen 443 ssl;  
http2 on;  

Прописываем файлы нашего сертификата и простое число для протокола Диффи-Хеллмана.

ssl_certificate /etc/letsencrypt/live/test.memory-online.ru/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/test.memory-online.ru/privkey.pem;  
ssl_trusted_certificate /etc/letsencrypt/live/test.memory-online.ru/chain.pem; 
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

Что обозначают другие директивы, которые начинаются с ssl я затрудняюсь ответить, но они нужны.
Заметили строчку include config/options-ssl-nginx.conf; Это мы подключим файл конфига options-ssl-nginx.conf
Следующего содержания

ssl_session_cache shared:le_nginx_SSL:10m;  
ssl_session_timeout 1440m;  
ssl_session_tickets off;  

ssl_protocols TLSv1.2 TLSv1.3;  
ssl_prefer_server_ciphers off;  

ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";

Точно описать каждую директиву у него я тоже не смогу.
Но и этого мало в файл nginx.conf добавим строчки

ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;  
ssl_prefer_server_ciphers on;

Вот теперь действительно всё. Можно "убивать" контейнеры и запускать их снова.

docker-compose down
docker system prune -a -f
docker-compose up -d

Радуемся протоколу https у нашего сайта https://test.memory-online.ru/ и сертификату, который выдал Kaspersky Anti-Virus.
Как настроить HTTPS для docker-compose и не заплакать, пошаговая инструкция
Но этого мало надо проверить наши настройки. Идём на сайт https://www.ssllabs.com/ssltest/ вбиваем наш домен и ждём результата. У меня получился A+. Надеюсь, у Вас тоже.
Как настроить HTTPS для docker-compose и не заплакать, пошаговая инструкция
Но если не нравится приведённый конфиг nginx? Можно создать свой, генераторов предостаточно. Например, первый и второй.
Думает уже пора пить шампанское? Нет, остался ещё ряд моментов.

Шаг 4. Последние шаги

Как настроить HTTPS для docker-compose и не заплакать, пошаговая инструкция
Дело в том, что каждый несколько месяцев фирма letsencrypt проверяет, а живой ли сайт, а всё ли у него хорошо. Если всё плохо, может отобрать сертификат. А если хорошо, надо бы сертификат перевыпустить. Проснуться однажды утром и обнаружить, что сайт не открывается, потому что просрочен сертификат идея так себе.
Поэтому необходимо написать небольшой скрипт cert_renew.sh

#!/bin/bash

COMPOSE="/usr/local/bin/docker-compose --ansi never"

cd /root || exit
$COMPOSE run certbot renew && $COMPOSE kill -s SIGHUP nginx

Который проверяет, а не пора бы обновить сертификат, а затем перезагружает nginx.
Этот файл необходимо скопировать на сервер и не забыть дать ему права на исполнение chmod u+x ./cert_renew.sh Но изначально лучше проверить с ключом --dry-run Этот ключ эмулирует весь процесс получения сертификата, но сам сертификат не сохраняет.

$COMPOSE run certbot renew --dry-run && $COMPOSE kill -s SIGHUP nginx

Если всё идеально, то этот скрипт можно поместить в планировщик, чтобы ровно в полночь каждый день он проверял, а не истёк ли сертификат.
Открываем планировщик crontab -e.
Добавляем строку

0 0 * * * /root/cert_renew.sh >> /var/log/cron.log 2>&1

Выходим из планировщика и перезагружаем его для применения настроек /etc/init.d/cron reload
Я прекрасно понимаю, что размещать этот файл в каталоге /root плохая идея, но это больше учебный пример.
Далее из файла Dockerfile удаляем строку установки mc. Файл примет такой вид

FROM nginx:1.25.3
RUN rm /etc/nginx/conf.d/default.conf

После изменения Dockerfile пересобираем наш проект через "убийство" и перезакачку контейнеров.
И последний штрих включаем firewall.

ufw allow ssh
ufw allow http
ufw allow https
ufw enable

Вот теперь действительно всё, можно пить шампанское и присвоить себе титул "паровозик, который смог". 🙂
Финальные настройки лежат вот здесь
Как настроить HTTPS для docker-compose и не заплакать, пошаговая инструкция
И ещё я настоятельно не рекомендую "перепрыгивать" через шаги. Например, если сначала пропишите финальный конфиг nginx без получения сертификатов. Nginx уйдёт в бесконечную перезагрузку, т.к. не сможет найти нужные файлы.

Если что-то показалось сложным, странным и даже пугающим пишите комментарии будем разбираться. 🙂