← Все записи
Kamal / Docker / Деплой

Первый деплой с Kamal: от HTML-файла до сайта с HTTPS

Разворачиваем простую страницу на своём сервере: Nginx в Docker, образы в GHCR, домен и обновления через Kamal.

Чтобы разобраться с деплоем, достаточно одной HTML-страницы. Не нужны фреймворк, база данных или сборщик: так проще увидеть, за что отвечает каждый инструмент.

Сделаем страницу, упакуем её в Docker и опубликуем на своём сервере через Kamal. В результате сайт будет открываться по домену с HTTPS, а для следующего обновления понадобится одна команда.

Как это работает

Nginx внутри контейнера раздаёт HTML. Docker-образ хранится в GitHub Container Registry, или GHCR. Kamal подключается к серверу по SSH, доставляет образ и переключает на него трафик через kamal-proxy.

Ноутбук → сборка образа → GHCR → контейнер на сервере
                                      ↑
Браузер → HTTPS → kamal-proxy ──────────┘

TLS завершается на прокси. Между прокси и контейнером используется HTTP внутри Docker-сети, поэтому Nginx в приложении слушает обычный порт 8080.

Что подготовить

На компьютере нужны Git, Ruby, Docker с Buildx и GitHub CLI. Для локальной проверки Docker должен быть запущен. Здесь использую Kamal 2.11.0 — ту же версию, с которой проверена конфигурация этого блога:

gem install kamal --version 2.11.0 --no-document
kamal version
docker version
docker buildx version

Ещё понадобятся домен, аккаунт GitHub и сервер. Пример рассчитан на чистый Ubuntu 24.04 с архитектурой amd64 и первоначальным SSH-доступом к root по ключу.

Во всех примерах замените example.com своим доменом, 203.0.113.10 — IP сервера, а your-github-user — своим GitHub username в нижнем регистре. Это демонстрационные значения, а не адреса для реального деплоя.

1. Создаём страницу

Начнём с каталога проекта:

mkdir hello-kamal
cd hello-kamal
git init -b main
mkdir -p public config .kamal

Создайте public/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Hello, Kamal</title>
    <style>
      body {
        max-width: 640px;
        margin: 15vh auto;
        padding: 24px;
        background: #141615;
        color: #e7e8e3;
        font: 18px/1.6 system-ui, sans-serif;
      }
      h1 { color: #b8d99b; }
    </style>
  </head>
  <body>
    <h1>Hello, Kamal.</h1>
    <p>A single HTML file, running on my own server.</p>
  </body>
</html>

2. Упаковываем сайт в Docker

Создайте config/nginx.conf:

server {
    listen 8080;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;
    absolute_redirect off;

    location = /up {
        access_log off;
        default_type text/plain;
        return 200 "ok\n";
    }

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

/up — адрес проверки готовности: Kamal проверит его перед переключением трафика. absolute_redirect off оставляет редиректы относительными, чтобы за внешним HTTPS-прокси в них не попал внутренний порт контейнера. Подробности есть в документации Nginx.

В корне проекта создайте Dockerfile:

FROM nginxinc/nginx-unprivileged:stable-alpine
LABEL org.opencontainers.image.source="https://github.com/your-github-user/hello-kamal"
COPY config/nginx.conf /etc/nginx/conf.d/default.conf
COPY public/ /usr/share/nginx/html/
EXPOSE 8080

Этот образ запускает Nginx без root. Метка org.opencontainers.image.source указывает, где лежат исходники.

В .dockerignore добавьте:

.git
.kamal
.env
.env.*

А в .gitignore:

.env
.env.*
.DS_Store

Проверим контейнер локально:

docker build -t hello-kamal .
docker run --rm -p 8080:8080 hello-kamal

Откройте http://localhost:8080. В другом терминале выполните:

curl --fail http://localhost:8080/up

Ожидаемый ответ — ok. Остановить контейнер можно через Ctrl+C в первом терминале.

3. Подготавливаем сервер и домен

Если на сервере уже есть Docker и пользователь для деплоя с доступом к нему, пропустите установку. Для чистого сервера подключитесь как root:

ssh root@203.0.113.10

Следующие команды выполняются на сервере. Они создают пользователя deploy и копируют ему ключи, с которыми уже разрешён вход в root:

apt-get update
apt-get install -y docker.io curl
systemctl enable --now docker

adduser --disabled-password --gecos "" deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
usermod -aG docker deploy
exit

Доступ к Docker фактически даёт административные возможности на сервере; используйте для этого собственный доверенный ключ. После добавления в группу важно открыть новое SSH-соединение. Проверьте с компьютера:

ssh deploy@203.0.113.10 'docker version --format "{{.Server.Version}}"'

У провайдера DNS добавьте A-запись @ → 203.0.113.10. Уберите конфликтующую парковку или URL Redirect для корня домена. Если есть AAAA-запись, она должна вести на работающий IPv6 этого же сервера; иначе удалите её. Порты 22, 80 и 443 должны быть доступны в firewall сервера и в панели хостинга.

Перед деплоем проверьте адрес:

dig +short A example.com

Он должен совпадать с IP сервера. Для автоматического HTTPS домен должен вести на сервер, а порт 443 — быть доступен снаружи. Эти требования описаны в настройке прокси Kamal.

4. Подключаем GHCR

Дальнейшие команды выполняются в каталоге проекта на компьютере. Войдите в GitHub CLI и добавьте право публикации образов:

gh auth login --hostname github.com
gh auth refresh --hostname github.com --scopes write:packages
gh auth token --hostname github.com | docker login ghcr.io --username your-github-user --password-stdin

Подтвердите вход в браузере. Последняя команда передаёт токен прямо в Docker, не печатая его в терминал.

В .kamal/secrets запишите только команду получения токена:

KAMAL_REGISTRY_PASSWORD=$(gh auth token --hostname github.com)

Такой файл можно хранить в Git: самого секрета в нём нет. Не заменяйте команду буквальным значением токена в коммитах. Kamal поддерживает получение секретов через команды; это описано в документации переменных окружения.

Для отдельного токена GHCR используйте Personal Access Token classic с нужными правами на packages. В GitHub Actions вместо личного токена применяется встроенный GITHUB_TOKEN. Оба варианта разобраны в руководстве GitHub по GHCR.

5. Описываем деплой

Создайте config/deploy.yml:

service: hello-kamal
image: your-github-user/hello-kamal

servers:
  web:
    - 203.0.113.10

proxy:
  ssl: true
  host: example.com
  app_port: 8080
  healthcheck:
    path: /up

registry:
  server: ghcr.io
  username: your-github-user
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

ssh:
  user: deploy

app_port должен совпадать с listen в Nginx. image задаёт имя образа внутри registry, а service — имя приложения для Kamal. Для сервера на ARM замените amd64 на arm64.

Этот вариант собирает образ на компьютере. Если Docker daemon есть только на сервере, замените блок builder на:

builder:
  arch: amd64
  remote: ssh://deploy@203.0.113.10
  local: false

При удалённой сборке локально всё равно нужны Docker CLI и Buildx. Опции описаны в документации сборщика Kamal.

6. Выполняем первый деплой

Сначала сохраните файлы в Git и создайте репозиторий на GitHub:

git add .
git commit -m "Add a single-page site and Kamal deployment"
gh repo create hello-kamal --private --source=. --remote=origin --push

Kamal по умолчанию собирает локальный Git checkout, поэтому незакоммиченные изменения в образ не попадут.

На подготовленном сервере для первого запуска приложения выполните:

kamal setup

Если Docker и совместимый kamal-proxy уже работают, например для другого приложения, можно сразу использовать kamal deploy. Не запускайте рядом второй reverse proxy на тех же портах.

После успешного завершения проверьте:

curl --fail --location --head https://example.com

В браузере должна открыться наша страница по HTTPS. Сертификатом занимается kamal-proxy, отдельная установка Certbot для этого примера не нужна.

7. Обновляем сайт и откатываем изменения

Измените текст в public/index.html, затем выполните:

git add public/index.html
git commit -m "Update homepage copy"
git push
kamal deploy

Kamal доставит новый образ и проверит /up перед переключением трафика. Для диагностики и просмотра сохранённых контейнеров:

kamal app logs
kamal app containers -q

Чтобы вернуться к предыдущему релизу, возьмите его версию из имени старого контейнера и выполните kamal rollback VERSION. Замените VERSION реальным идентификатором. Откат требует, чтобы предыдущий контейнер или образ ещё был доступен; детали — в руководстве по rollback.

Если сайт не открывается

Сначала разделите DNS и работу сервера. Сравните обычный запрос с запросом к правильному IP:

curl --head --location --max-time 15 https://example.com
curl --head --location --max-time 15 --resolve example.com:443:203.0.113.10 https://example.com

Если второй работает, а первый — нет, проверьте, какой адрес возвращает DNS. После смены A-записи старый IP может оставаться в кеше роутера или провайдера до истечения TTL. Очистка кеша браузера не обновляет DNS роутера.

Если не работает и запрос по IP с --resolve, смотрите логи приложения, доступность портов и настройки домена. Не добавляйте -k к проверке HTTPS: иначе можно пропустить ошибку сертификата.

Теперь для публикации новой версии достаточно коммита и kamal deploy. Следующий шаг — перенести эту команду в GitHub Actions и запускать её после успешных проверок при слиянии в main.