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.