Docker для веброзробника: від першого запуску до продакшну

Docker 4 лип. 2026 р.
 Docker для веброзробника: від першого запуску до продакшну

Docker давно перестав бути іграшкою для DevOps-ентузіастів — сьогодні це базовий інструмент будь-якого веброзробника. Він розв'язує вічну проблему «на моїй машині працює»: застосунок разом із усіма залежностями пакується в контейнер, який однаково запуститься на ноутбуку, тестовому й бойовому сервері.

Ця стаття — практичний шлях від першого docker run до готового до продакшну розгортання. Без глибокої теорії про cgroups і namespaces — тільки те, що реально знадобиться, щоб підняти й обслуговувати свій сайт у контейнерах.

Встановлення

На Ubuntu/Debian ставимо офіційний Docker Engine (не docker.io зі стандартних репозиторіїв — він застарілий):

curl -fsSL https://get.docker.com | sh

# Щоб не писати sudo перед кожною командою
sudo usermod -aG docker $USER
newgrp docker

# Перевірка
docker run hello-world

Сучасний Docker уже містить плагін Compose, тож окремо його ставити не треба. Перевірка: docker compose version (саме через пробіл, стара команда docker-compose з дефісом — deprecated).

Крок 1: перший контейнер

Найкоротший спосіб відчути Docker — підняти nginx однією командою:

docker run --rm -p 8080:80 nginx

Розбираємо по частинах:

  • run — запустити контейнер із образу;
  • --rm — видалити контейнер після зупинки (щоб не сміттярити);
  • -p 8080:80 — прокинути порт хоста 8080 → на порт 80 усередині контейнера;
  • nginx — образ із Docker Hub (завантажиться автоматично).

Відкриваємо http://localhost:8080 — бачимо привітальну сторінку nginx. Зупиняємо через Ctrl+C. Вітаю, ви щойно запустили вебсервер, не встановивши його в систему.

Крок 2: образи, контейнери, том — ключові поняття

Щоб не плутатись далі, розкладемо три сутності:

  • Образ (image) — незмінний шаблон, «зліпок» файлової системи із застосунком. Аналог класу в ООП.
  • Контейнер (container) — запущений екземпляр образу. Аналог об'єкта. Контейнерів з одного образу можна підняти скільки завгодно.
  • Том (volume) — постійне сховище, яке переживає перезапуск і видалення контейнера. Усе, що всередині контейнера, при його видаленні зникає; усе, що в томі, — лишається.

Базові команди для орієнтування:

docker ps              # запущені контейнери
docker ps -a           # усі, включно зі зупиненими
docker images          # локальні образи
docker logs -f <імʼя>   # логи контейнера в реальному часі
docker exec -it <імʼя> bash   # зайти всередину контейнера
docker stats           # споживання CPU/RAM у реальному часі

Крок 3: від run до docker-compose

Довгі команди docker run з десятком прапорців незручно набирати руками й неможливо документувати. Тому в реальній роботі майже завжди використовують Docker Compose — описуємо весь стек в одному YAML-файлі.

Приклад: типовий стек для PHP-сайту — веб, PHP-FPM і база. Файл docker-compose.yml:

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./src:/var/www/html
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - php

  php:
    image: php:8.3-fpm-alpine
    volumes:
      - ./src:/var/www/html

  db:
    image: mariadb:11
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: mysite
      MYSQL_USER: mysite
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - dbdata:/var/lib/mysql

volumes:
  dbdata:

Запуск усього стеку однією командою:

docker compose up -d      # -d = у фоновому режимі
docker compose logs -f    # логи всіх сервісів
docker compose down       # зупинити й видалити контейнери

Зверніть увагу на два типи volumes у прикладі:

  • Bind mount (./src:/var/www/html) — прив'язка каталогу з хоста. Ідеально для розробки: правите файл локально — зміни одразу в контейнері.
  • Named volume (dbdata) — керований Docker'ом том для даних бази. Переживає docker compose down, дані не губляться.

Лишився файл nginx.conf, який ми змонтували в контейнер nginx. Мінімальний робочий варіант для PHP-FPM:

server {
    listen 80;
    server_name _;
    root /var/www/html;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass php:9000;      # ім'я сервісу і порт PHP-FPM
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    location ~ /\.(?!well-known) {
        deny all;                   # не віддавати .env, .git тощо
    }
}

Ключовий рядок — fastcgi_pass php:9000: nginx звертається до PHP-FPM за іменем сервісу з compose. І зверніть увагу, що код змонтовано в обидва контейнери за однаковим шляхом: nginx віддає статику сам, а PHP-файли передає в FPM, який має бачити їх за тим самим шляхом.

Docker Compose поглиблено

Базового up -d вистачає для старту, але справжня сила Compose розкривається в кількох речах, які варто знати з самого початку.

Мережі та зв'язок між сервісами

Головне непорозуміння новачків: «як контейнер бачить базу?». Відповідь — Compose автоматично створює спільну мережу для всіх сервісів стеку, і кожен сервіс доступний іншим за своїм іменем. У нашому прикладі PHP-код підключається до бази не за localhost і не за IP, а за іменем сервісу — db:

$pdo = new PDO('mysql:host=db;dbname=mysite', 'mysite', $password);
//                     ^^ ім'я сервісу з docker-compose.yml

Саме тому порт бази не треба публікувати назовні (ports:) — контейнери спілкуються всередині приватної мережі Docker. Публікувати порт бази варто лише тоді, коли до неї треба підключитись із хоста (наприклад, з DBeaver для дебагу) — і тоді прив'язуйте його до 127.0.0.1:

  db:
    ports:
      - "127.0.0.1:3306:3306"   # доступ лише з localhost, не з інтернету

За потреби ізолювати групи сервісів (наприклад, щоб фронтенд не мав доступу до бази напряму) створюють кілька мереж явно:

services:
  nginx:
    networks: [frontend]
  php:
    networks: [frontend, backend]
  db:
    networks: [backend]        # db недосяжна з nginx

networks:
  frontend:
  backend:

Змінні середовища: три способи

Передати конфігурацію в контейнер можна кількома шляхами, від найпростішого до найчистішого:

  php:
    # 1. Напряму в compose
    environment:
      APP_ENV: production
      APP_DEBUG: "false"

    # 2. З окремого файлу (зручно для довгих списків)
    env_file:
      - ./app.env

    # 3. Підстановка зі змінних хоста / .env поруч із compose
    environment:
      DB_PASSWORD: ${DB_PASSWORD}

Файл .env (поруч із docker-compose.yml) Compose читає автоматично й підставляє значення в ${...}. Не плутайте його з env_file: — перший впливає на сам compose-файл, другий передає змінні всередину контейнера.

Кілька файлів: dev і prod без дублювання

Один із найкорисніших прийомів. Базовий docker-compose.yml описує спільне, а docker-compose.override.yml — те, що потрібно лише для розробки (bind mount вихідників, відкриті порти, xdebug). Compose автоматично зливає обидва файли:

# docker-compose.override.yml — підхоплюється сам при `up`
services:
  php:
    volumes:
      - ./src:/var/www/html    # монтуємо код для гарячої правки
    environment:
      APP_DEBUG: "true"
  db:
    ports:
      - "127.0.0.1:3306:3306"  # відкриваємо базу для локального дебагу

А для продакшну тримаємо окремий файл і зливаємо явно, щоб override НЕ підхопився:

# Локально — base + override автоматично
docker compose up -d

# На проді — base + prod, без dev-override
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

DRY через YAML-якорі

Коли кілька сервісів мають однакові налаштування (логи, restart-політика, лейбли), їх можна винести в якір і перевикористати через <<: *anchor:

x-common: &common
  restart: unless-stopped
  logging:
    driver: json-file
    options: { max-size: "10m", max-file: "3" }

services:
  php:
    <<: *common
    build: .
  nginx:
    <<: *common
    image: nginx:alpine

Секція x-common з префіксом x- ігнорується Docker'ом як «розширення» — вона існує лише щоб на неї посилатись. Тепер restart-політику й логування описано в одному місці для всіх сервісів.

Profiles: вмикати сервіси за потреби

Іноді частина сервісів потрібна не завжди — наприклад, adminer для роботи з базою чи воркер черг. profiles дозволяє тримати їх в одному файлі, але піднімати вибірково:

  adminer:
    image: adminer
    ports:
      - "127.0.0.1:8081:8080"
    profiles: [tools]
docker compose up -d                    # adminer НЕ підніметься
docker compose --profile tools up -d    # підніметься і adminer

Корисні команди Compose

docker compose config          # показати підсумковий склад (base+override) — дебаг помилок
docker compose ps              # статус сервісів стеку
docker compose restart php     # перезапустити один сервіс
docker compose up -d --build   # перезібрати образи й підняти
docker compose exec php sh     # зайти в контейнер сервісу
docker compose down -v         # знести стек РАЗОМ із томами (обережно!)

Особливо цінна перша — docker compose config: вона показує, у що саме перетворюється ваш стек після злиття всіх файлів і підстановки змінних. Перше, що варто запустити, коли «чомусь не працює».

Крок 4: власний образ через Dockerfile

Готові образи хороші, але рано чи пізно знадобиться свій — із потрібними PHP-розширеннями, скопійованим кодом, налаштуваннями. Це описується у Dockerfile:

FROM php:8.3-fpm-alpine

# Системні залежності та PHP-розширення
RUN apk add --no-cache libpng-dev libzip-dev \
    && docker-php-ext-install pdo_mysql gd zip opcache

# Composer у базовому образі PHP немає — беремо бінарник з офіційного образу
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

# Робочий каталог
WORKDIR /var/www/html

# Спочатку — тільки залежності (кешування шарів!)
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader

# Потім — увесь код
COPY . .
RUN composer dump-autoload --optimize --no-dev

EXPOSE 9000
CMD ["php-fpm"]

Рядок COPY --from=composer:2 — зручний прийом: замість встановлення composer скриптом ми просто копіюємо готовий файл з офіційного образу. Автозавантажувач генеруємо вже після копіювання коду, бо до цього класів проєкту в образі ще немає.

Ключовий трюк тут — порядок шарів. Docker кешує кожну інструкцію. Якщо спершу скопіювати composer.json і поставити залежності, а код — окремим шаром нижче, то при зміні коду (але не залежностей) Docker перевикористає кеш і не перевстановлюватиме composer щоразу. Це економить хвилини на кожній збірці.

Збірка й використання:

docker build -t mysite:latest .

А в compose замість image: вказуємо build:

  php:
    build: .
    volumes:
      - ./src:/var/www/html

.dockerignore: що не тягнути в образ

Команда COPY . . копіює в образ усе з папки проєкту, включно з .git, node_modules і файлом .env з паролями. Щоб цього уникнути, поруч із Dockerfile кладемо .dockerignore — він працює як .gitignore:

.git
.env
.env.*
!.env.example
node_modules
vendor
docker-compose*.yml
*.log
tests

Образ стає меншим, збірка швидшою (Docker не пересилає зайві гігабайти), а секрети не потрапляють в образ, який ви, можливо, колись запушите в реєстр.

Крок 5: багатоетапна збірка (multi-stage)

Для фронтенду чи компільованих мов образ роздувається інструментами збірки, які в продакшні не потрібні. Multi-stage build дозволяє зібрати в «жирному» образі, а в фінальний перекласти лише результат:

# Етап збірки
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Фінальний образ — тільки зібрана статика
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html

Результат — фінальний образ важить десятки мегабайт замість сотень: у ньому немає ані Node.js, ані node_modules, тільки готові файли й nginx.

Щоденна робота з кодом у контейнері

Коли PHP живе в контейнері, composer, міграції та консольні команди фреймворку теж запускаються там. На хості навіть не потрібно ставити PHP.

# Команда в уже запущеному контейнері
docker compose exec php composer install
docker compose exec php php artisan migrate      # Laravel
docker compose exec php php yii migrate          # Yii2

# Одноразовий контейнер (стек може бути зупинений)
docker compose run --rm php composer require monolog/monolog

# Зайти в контейнер і працювати як у звичайному терміналі
docker compose exec php sh

Різниця: exec виконує команду в працюючому контейнері, а run --rm створює новий, виконує команду і видаляє його. Для разових задач на зупиненому стеку — run, для всього іншого — exec.

Проблема з правами на файли

Класична ситуація з bind mount: composer у контейнері працює від root, і створені ним файли (vendor/, кеш) на хості належать root. Потім ви не можете їх редагувати чи видалити без sudo. Рішення — запускати команди від свого користувача:

docker compose run --rm --user "$(id -u):$(id -g)" php composer install

Якщо файли вже належать root, повернути їх собі: sudo chown -R $USER:$USER src/.

Допоміжні сервіси: черги та cron

Воркер черг — це той самий образ PHP, але з іншою командою запуску. Не треба окремого Dockerfile, достатньо ще одного сервісу:

  queue:
    build: .
    command: php yii queue/listen --verbose
    restart: unless-stopped
    depends_on:
      - db

Так само для планувальника: замість cron усередині контейнера простіше лишити cron на хості й викликати docker compose exec -T php php yii cron/run. Ключ -T вимикає псевдотермінал, без нього команда з cron завершиться помилкою «the input device is not a TTY».

Крок 6: що змінюється для продакшну

Стек, який працює на ноутбуку, і той самий стек у бою — це різні речі. Ось що обов'язково додати.

Політика перезапуску

Щоб контейнери самі піднімались після падіння чи перезавантаження сервера:

services:
  php:
    restart: unless-stopped

unless-stopped — оптимальний вибір: перезапускає завжди, крім випадку, коли ви зупинили контейнер вручну.

Healthcheck

Docker має знати, що контейнер не просто «запущений», а реально працює:

  db:
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect"]
      interval: 10s
      timeout: 5s
      retries: 5

У парі з depends_on: condition: service_healthy це гарантує, що застосунок стартує лише після того, як база реально готова приймати з'єднання, а не просто «контейнер запустився»:

  php:
    depends_on:
      db:
        condition: service_healthy

Простий depends_on: [db] чекає лише запуску контейнера бази, а MariaDB після старту ще кілька секунд ініціалізується. Саме тому застосунок іноді падає з «Connection refused» при першому up, а з другої спроби працює.

Секрети — не в git

Паролі й ключі виносимо у файл .env, який обов'язково додаємо в .gitignore. У репозиторій кладемо тільки .env.example із порожніми значеннями. Compose підхоплює .env автоматично:

# .env (не в git!)
DB_ROOT_PASSWORD=надійний_пароль
DB_PASSWORD=інший_пароль

Ліміти ресурсів

Щоб один контейнер, який «поїхав», не з'їв усю пам'ять сервера:

  php:
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"

Керування логами

За замовчуванням логи контейнерів ростуть безмежно й здатні забити диск. Обмежуємо ротацію:

  php:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Не запускати від root

Усередині контейнера процес за замовчуванням працює від root. Для продакшну краще створити непривілейованого користувача в Dockerfile і вказати USER appuser — якщо контейнер зламають, зловмисник не отримає root-прав навіть усередині.

В офіційних образах PHP вже є користувач www-data, тож достатньо віддати йому файли й переключитися наприкінці Dockerfile:

COPY --chown=www-data:www-data . .
USER www-data

Для образів, де готового користувача немає, створюємо свого: RUN addgroup -S app && adduser -S app -G app (Alpine) і далі USER app.

Крок 7: reverse proxy й HTTPS

У продакшні перед контейнерами зазвичай стоїть reverse proxy, який завершує TLS і маршрутизує домени. Два популярні підходи:

  • Nginx на хості + certbot — класика, повний контроль, порти контейнерів прив'язуємо тільки до 127.0.0.1, щоб вони не стирчали назовні.
  • Traefik у контейнері — автоматично підхоплює нові сервіси через Docker-мітки й сам отримує Let's Encrypt-сертифікати. Зручно, коли сервісів багато.

Приклад для першого варіанту. У compose публікуємо порт nginx лише на localhost:

  nginx:
    ports:
      - "127.0.0.1:8080:80"

А nginx на хості проксує домен на цей порт:

server {
    server_name mysite.com www.mysite.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Після цього sudo certbot --nginx -d mysite.com -d www.mysite.com сам отримає сертифікат і допише в конфіг налаштування HTTPS. Заголовки X-Forwarded-* потрібні, щоб застосунок знав справжній IP відвідувача і те, що запит прийшов по HTTPS.

Якщо сервер за NAT без білого IP (типово для університетської чи домашньої інфраструктури) — Cloudflare Tunnel прокидає трафік назовні без відкриття портів узагалі, достатньо додати контейнер cloudflared у той самий compose.

Крок 8: оновлення та бекапи

Оновлення образів:

docker compose pull        # завантажити нові версії образів
docker compose up -d       # перестворити контейнери з оновленнями
docker image prune -f      # прибрати старі непотрібні образи

Бекап named volume (наприклад, бази) — запускаємо тимчасовий контейнер, який пакує том у архів:

docker run --rm \
  -v mysite_dbdata:/data \
  -v $(pwd):/backup \
  alpine tar czf /backup/dbdata-$(date +%F).tar.gz -C /data .

Для бази даних надійніше робити логічний дамп, а не копіювати файли «наживо»:

docker compose exec -T db sh -c \
  'exec mariadb-dump -u root -p"$MYSQL_ROOT_PASSWORD" mysite' > backup-$(date +%F).sql

Два нюанси. У образах MariaDB 11 утиліта називається mariadb-dump (старої назви mysqldump там уже немає, для образу mysql лишається mysqldump). А команда обгорнута в sh -c в одинарних лапках, щоб змінна з паролем підставилась усередині контейнера, де вона задана, а не на хості.

Відновлення — бекап, який ніколи не пробували відновити, не можна вважати бекапом:

# База з дампу
docker compose exec -T db sh -c \
  'exec mariadb -u root -p"$MYSQL_ROOT_PASSWORD" mysite' < backup-2026-10-23.sql

# Том з архіву (стек має бути зупинений)
docker compose down
docker run --rm \
  -v mysite_dbdata:/data \
  -v $(pwd):/backup \
  alpine sh -c "rm -rf /data/* && tar xzf /backup/dbdata-2026-10-23.tar.gz -C /data"
docker compose up -d

Щоб бекапи робились самі, додайте дамп у cron на хості, наприклад щоночі о 3:00: 0 3 * * * cd /srv/mysite && ./backup.sh, і не забувайте копіювати архіви на інший сервер або в хмару.

Гігієна: не засмічуйте систему

Зупинені контейнери, старі образи й «завислі» томи з часом з'їдають десятки гігабайтів. Періодичне прибирання:

docker system df      # скільки місця займає Docker
docker system prune   # прибрати зупинене й непотрібне
docker system prune -a --volumes   # агресивне (обережно з --volumes!)

--volumes видаляє невикористовувані томи — переконайтеся, що серед них немає потрібних даних.

Діагностика: коли щось не працює

Контейнер не стартує, постійно перезапускається або сайт віддає 502. Алгоритм пошуку причини майже завжди однаковий.

1. Подивитися статус і логи. У 90% випадків відповідь у логах:

docker compose ps                  # статус: Up, Restarting, Exited (код)
docker compose logs --tail=100 php # останні 100 рядків логу сервісу
docker compose logs -f --since=10m # усі сервіси за останні 10 хвилин

2. Розшифрувати код виходу. Статус Exited (N) підказує причину:

  • 0 — процес просто завершився. Типово, якщо в CMD команда, що не працює у фоні;
  • 1 — помилка застосунку, дивіться логи;
  • 137 — контейнер вбили через нестачу пам'яті (OOM) або docker kill. Перевірте ліміти memory;
  • 127 — команду не знайдено: помилка в CMD/entrypoint або немає потрібної утиліти в образі.

3. Подивитися конфігурацію контейнера:

docker inspect mysite-php-1 --format '{{.State.OOMKilled}}'   # чи вбито через пам'ять
docker inspect mysite-php-1 --format '{{json .Mounts}}'       # що куди змонтовано
docker compose exec php env                                   # які змінні реально бачить контейнер

4. Перевірити мережу зсередини. Якщо застосунок не бачить базу:

docker compose exec php ping -c 2 db       # чи резолвиться ім'я сервісу
docker compose exec php nc -zv db 3306     # чи відкритий порт

5. Контейнер падає одразу і в нього не зайти. Запустіть образ з оболонкою замість основної команди й перевірте все вручну:

docker compose run --rm --entrypoint sh php

Найчастіші причини, з якими зустрічаються всі:

  • «port is already allocated» — порт зайнятий іншим процесом на хості (часто системний nginx чи Apache). Знайти винуватця: sudo ss -tulpn | grep :80.
  • 502 Bad Gateway — nginx не достукався до PHP-FPM: сервіс php не запущений, помилка в fastcgi_pass або FPM слухає не той порт.
  • Зміни в коді не з'являються — код скопійовано в образ через COPY, а не змонтовано. Потрібно docker compose up -d --build або bind mount для розробки.
  • Зміни в compose не застосувались — restart не перечитує конфіг. Потрібно docker compose up -d, який перестворить змінені контейнери.

Типові помилки новачків

  • Дані в контейнері замість тома. Забули винести дані бази в volume → docker compose down → усе зникло. Усе, що має пережити перезапуск, — тільки в томах.
  • latest у продакшні. Тег latest непередбачуваний: сьогодні одна версія, завтра інша. У бою фіксуйте конкретні теги (mariadb:11.4, а не mariadb:latest).
  • Паролі в docker-compose.yml. Файл потрапляє в git → секрети злиті. Тільки через .env.
  • Порти назовні без потреби. ports: "3306:3306" для бази відкриває її всьому інтернету. Якщо база потрібна лише іншим контейнерам — порт узагалі не публікуємо, вони спілкуються через внутрішню мережу Docker за іменем сервісу.
  • Ігнорування .dockerignore. Без нього в образ потрапляють node_modules, .git і локальні файли, роздуваючи його й уповільнюючи збірку.

Висновок

Шлях від першого docker run до продакшну — це поступове нарощування: спочатку запуск готового образу, потім опис стеку в Compose, власний Dockerfile із кешуванням шарів і .dockerignore, а для бою — політики перезапуску, healthcheck'и, ліміти ресурсів, винесені секрети, reverse proxy й налаштовані бекапи. Кожен крок додає надійності, і жоден не вимагає глибокого занурення в нутрощі Linux.

Головна цінність Docker для веброзробника — відтворюваність: той самий docker compose up -d підніме ідентичне середовище на будь-якому сервері. А коли весь стек описано в кількох текстових файлах, його легко версіонувати, ревʼювити й переносити. У наступних матеріалах розберемо конкретні продакшн-кейси: розгортання складних застосунків і публікацію через Cloudflare Tunnel без білого IP.