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, дані не губляться.
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
# Робочий каталог
WORKDIR /var/www/html
# Спочатку — тільки залежності (кешування шарів!)
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts
# Потім — увесь код
COPY . .
EXPOSE 9000
CMD ["php-fpm"]
Ключовий трюк тут — порядок шарів. Docker кешує кожну інструкцію. Якщо спершу скопіювати composer.json і поставити залежності, а код — окремим шаром нижче, то при зміні коду (але не залежностей) Docker перевикористає кеш і не перевстановлюватиме composer щоразу. Це економить хвилини на кожній збірці.
Збірка й використання:
docker build -t mysite:latest .
А в compose замість image: вказуємо build:
php:
build: .
volumes:
- ./src:/var/www/html
Крок 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.
Крок 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 це гарантує, що застосунок стартує лише після того, як база реально готова приймати з'єднання, а не просто «контейнер запустився».
Секрети — не в 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-прав навіть усередині.
Крок 7: reverse proxy й HTTPS
У продакшні перед контейнерами зазвичай стоїть reverse proxy, який завершує TLS і маршрутизує домени. Два популярні підходи:
- Nginx на хості + certbot — класика, повний контроль, порти контейнерів прив'язуємо тільки до
127.0.0.1, щоб вони не стирчали назовні. - Traefik у контейнері — автоматично підхоплює нові сервіси через Docker-мітки й сам отримує Let's Encrypt-сертифікати. Зручно, коли сервісів багато.
Якщо сервер за 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 \
mysqldump -u root -p"$DB_ROOT_PASSWORD" mysite > backup.sql
Гігієна: не засмічуйте систему
Зупинені контейнери, старі образи й «завислі» томи з часом з'їдають десятки гігабайтів. Періодичне прибирання:
docker system df # скільки місця займає Docker
docker system prune # прибрати зупинене й непотрібне
docker system prune -a --volumes # агресивне (обережно з --volumes!)
--volumes видаляє невикористовувані томи — переконайтеся, що серед них немає потрібних даних.
Типові помилки новачків
- Дані в контейнері замість тома. Забули винести дані бази в 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 із кешуванням шарів, а для бою — політики перезапуску, healthcheck'и, ліміти ресурсів, винесені секрети, reverse proxy й налаштовані бекапи. Кожен крок додає надійності, і жоден не вимагає глибокого занурення в нутрощі Linux.
Головна цінність Docker для веброзробника — відтворюваність: той самий docker compose up -d підніме ідентичне середовище на будь-якому сервері. А коли весь стек описано в кількох текстових файлах, його легко версіонувати, ревʼювити й переносити. У наступних матеріалах розберемо конкретні продакшн-кейси: розгортання складних застосунків і публікацію через Cloudflare Tunnel без білого IP.