SSD Nodes Learn
راهنماها Matt Connorتوسط Matt Connor · به‌روزرسانی شده 2026-07-24

نصب Docker Compose روی Ubuntu 24.04

آموزش نصب Docker Engine و Compose v2 روی Ubuntu 24.04، راه‌اندازی دو سرویس در compose.yml و رفع مشکل پورت‌های ufw و امنیت volumeها در VPS.

آنچه در حال ساخت آن هستید

Docker Compose زیرساخت اصلی تقریباً تمام سرویس‌های این سایت است. Nextcloud، Vaultwarden، n8n، Immich و Rocket.Chat؛ تمامی این راهنماها با عبارت "این فایل compose را بنویسید" شروع می‌شوند و این صفحه توضیح می‌دهد که آن فایل دقیقاً به چه معناست. شما Docker Engine و افزونه Compose v2 را از مخزن رسمی apt شرکت Docker روی Ubuntu 24.04 نصب خواهید کرد. سپس یک پشته (stack) واقعی شامل دو سرویس را راه‌اندازی می‌کنید: Miniflux (یک RSS reader کوچک) و PostgreSQL. این ترکیب، تمام الگوهای مورد استفاده در اپلیکیشن‌های بزرگتر را تمرین می‌کند: استفاده از تصاویر ثابت (pinned images)، پایگاه داده دارای healthcheck، یک volume نام‌گذاری شده، secrets در یک فایل .env، و پورت‌هایی که فقط برای localhost منتشر شده‌اند.

نصب تنها 5 دقیقه زمان می‌برد. بخش‌های باقی‌مانده این راهنما به مواردی می‌پردازد که بعداً باعث بروز مشکل می‌شوند: دسترسی گروه docker به root تحت نامی دیگر، پورت‌های منتشر شده که مستقیماً از قوانین ufw عبور می‌کنند، و یک flag در docker compose down که بدون درخواست تایید، پایگاه داده شما را حذف می‌کند.

پیش‌نیازها: یک VPS از نوع KVM با نسخه تازه Ubuntu 24.04، یک کاربر با دسترسی sudo، و حداقل 1 گیگابایت RAM. نصب از قبل موجود Docker نیز مشکلی ندارد؛ بخش اول توضیح می‌دهد که چه مواردی باید حذف شوند.

از مخزن Docker نصب کنید، نه از مخزن Ubuntu

قبل از اجرای اولین دستور، باید از دو اشتباه رایج جلوگیری کنید. بسته docker.io خودِ Ubuntu کار می‌کند، اما از نسخه‌های Docker عقب‌تر است و ساختار پلاگین مورد نیاز سایر بخش‌ها را ندارد. همچنین فایل باینری standalone docker-compose (همان که خط تیره دارد) مربوط به Compose v1 است: این نسخه مبتنی بر Python است، از سال 2023 از پشتیبانی خارج شده و دلیل خرابی آموزش‌های قدیمی است. Compose امروزی docker compose (با فاصله) است؛ یک پلاگین CLI که از همان مخزن موتور Docker نصب می‌شود.

اگر هر کدام از موارد بالا از قبل روی سیستم نصب شده است، ابتدا آن‌ها را پاک کنید — از جمله docker-compose-v2 که بسته‌بندی اختصاصی Ubuntu برای این پلاگین است؛ تا همه چیز از یک مخزن واحد دریافت شود:

sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc

خروجی Package 'docker.io' is not installed, so not removed خروجی معمولی در یک VPS تازه است. سپس مخزن Docker را اضافه و نصب کنید:

sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

هر سه لایه را بررسی کنید:

docker --version
docker compose version
sudo docker run --rm hello-world

دو مورد اول رشته‌های نسخه را چاپ می‌کنند — Docker Compose version v2.x.x تایید می‌کند که شما پلاگین را دارید، نه باینری منسوخ شده v1 را. دستور hello-world باید با Hello from Docker! پایان یابد. این بسته سرویس را در هنگام بوت فعال می‌کند؛ دستور systemctl is-enabled docker خروجی enabled را چاپ می‌کند.

گروه docker متعلق به root است — با دقت تصمیم بگیرید

در حال حاضر هر دستور docker به sudo نیاز دارد، زیرا socket مربوط به daemon در مسیر /var/run/docker.sock متعلق به کاربر root و گروه docker است. بدون عضویت در این گروه، با پرکاربردترین خطای Docker مواجه می‌شوید:

permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sock

راه حل استاندارد:

sudo usermod -aG docker $USER

عضویت در گروه هنگام ورود به سیستم (login) اعمال می‌شود، بنابراین خطا در shell فعلی شما باقی می‌ماند. برای این session دستور newgrp docker را اجرا کنید، یا از حساب کاربری خارج شده و دوباره وارد شوید؛ سپس دستور id باید docker را در لیست گروه‌های شما نمایش دهد.

حالا بخش صادقانه و صریح: عضویت در گروه docker معادل دسترسی root در host است. این دسترسی "شبیه به root" یا "ارتقای یافته" نیست — بلکه خودِ root است. هر کسی که در آن گروه باشد می‌تواند docker run --rm -it -v /:/host alpine chroot /host را اجرا کند و مالک کل filesystem شود، بدون اینکه نیاز به رمز عبور باشد. این گروه برای راحتی استفاده ایجاد شده است، نه برای محدودسازی (containment).

حالت rootless در Docker جایگزین واقعی است — در این حالت خودِ daemon با کاربر بدون امتیاز (unprivileged) شما اجرا می‌شود. این حالت هزینه‌های زیر را دارد: پورت‌های زیر 1024 نیاز به تنظیمات اضافی دارند، شبکه از طریق یک shim در فضای کاربر (userspace) با سربار (overhead) قابل توجه اجرا می‌شود، و برخی images بدون دسترسی root واقعی دچار مشکل می‌شوند. در یک VPS که تنها مدیر آن از قبل sudo دارد، تغییر گروه در عمل تغییری ایجاد نمی‌کند و تمام راهنماهای اینجا همین فرض را دارند — فقط هرگز این دسترسی را طوری اعطا نکنید که انگار سطح دسترسی کمتری از sudo دارد.

Anatomy of a compose file

برای هر stack یک دایرکتوری مجزا در نظر بگیرید — نام دایرکتوری تبدیل به نام پروژه می‌شود که پیشوند تمامی containerها، networkها و volumeها خواهد بود:

sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/miniflux

فایل compose.yml را ایجاد کنید (نام مدرن؛ docker-compose.yml همچنان کار می‌کند). از کلید قدیمی version: استفاده نکنید — این کلید منسوخ شده است و Compose در صورت مشاهده آن هشدار می‌دهد.

services:
  miniflux:
    image: miniflux/miniflux:2.2.9
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      - DATABASE_URL=postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
      - RUN_MIGRATIONS=1
      - CREATE_ADMIN=1
      - ADMIN_USERNAME=admin
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_USER=miniflux
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=miniflux
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "miniflux", "-d", "miniflux"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db-data:

هر خط بالا یک تصمیم است. آن‌ها را یکی یکی بررسی کنید.

Pin image versions — :latest plus a pull is an unattended upgrade

از postgres:16-alpine استفاده کنید، نه postgres:latest. یک tag ثابت نیست: با هر بار pull کردن، :latest دوباره به آخرین نسخه‌ای که maintainer منتشر کرده است اشاره می‌کند. این موضوع را با عادت به‌روزرسانی روتینی که قرار است یاد بگیرید ترکیب کنید — docker compose pull && docker compose up -d — و نتیجه این می‌شود که :latest یعنی پرش به نسخه اصلی (major-version) دقیقاً زمانی اتفاق می‌افتد که upstream آن را منتشر کند، نه زمانی که شما انتخاب کنید. در مورد PostgreSQL این یک فرضیه نیست: یک پرش ناگهانی از 16 به 17 باعث می‌شود container در یک loop کرش مداوم قرار بگیرد، زیرا دایرکتوری داده با نسخه جدید ناسازگار است؛ ارتقای نسخه اصلی Postgres نیازمند dump و restore است، نه فقط یک restart.

حداقل نسخه اصلی را ثابت کنید (postgres:16-alpine نسخه‌های patch 16.x را دنبال می‌کند)، و اپلیکیشن‌ها را به یک نسخه دقیق مانند miniflux/miniflux:2.2.9 محدود کنید — هنگام نوشتن فایل، صفحه release پروژه را چک کنید و از نسخه‌ای که فعلاً موجود است استفاده کنید. در این صورت، یک ارتقا تبدیل به یک تغییر تک‌خطی می‌شود که شما آگاهانه انجام داده‌اید و در git diff قابل مشاهده است.

Publish to 127.0.0.1, because Docker walks around ufw

"127.0.0.1:8080:8080" — آدرس host، پورت host، پورت container. اکثر آموزش‌ها از "8080:8080" استفاده می‌کنند که مخفف 0.0.0.0:8080:8080 است: گوش دادن روی تمام interfaceها، از جمله interface عمومی.

تله اینجاست و تقریباً همه یک بار با آن درگیر می‌شوند. Docker یک پورت را با نوشتن یک rule از نوع DNAT منتشر می‌کند که مقصد packet را قبل از فیلتر کردن، به IP داخلی container تغییر می‌دهد؛ بنابراین packet مسیر FORWARD را طی می‌کند و هرگز به INPUT که قوانین ufw در آن قرار دارند، نمی‌رسد. sudo ufw deny 8080 موفقیت را گزارش می‌دهد، ufw status نشان می‌دهد که پورت مسدود شده است، اما سرویس همچنان به کل اینترنت پاسخ می‌دهد. فایروال شما خراب نیست؛ بلکه طبق طراحی، از آن عبور داده می‌شود. Why Docker bypasses ufw, and how to filter container traffic for real مکانیسم و راه حل DOCKER-USER برای پورت‌هایی که باید عمومی باقی بمانند را بررسی می‌کند.

عادتی که باعث ناپدید شدن این مشکل می‌شود: پورت‌های منتشر شده را به 127.0.0.1 bind کنید، مگر اینکه دلیل خاصی برای عدم انجام آن داشته باشید، و برای هر چیزی که باید در معرض جهان باشد، یک reverse proxy در جلو قرار دهید. این دقیقاً همان چیزی است که Traefik reverse proxy guide به عنوان مرحله بعدی این صفحه می‌سازد — یک container که پورت‌های 80 و 443 را در اختیار دارد و با استفاده از TLS، ترافیک را بر اساس hostname به بقیه سرویس‌ها هدایت می‌کند. (اگر از تنظیمات قدیمی Traefik v2 استفاده می‌کنید؟ Traefik v2 to v3 migration guide تغییرات نام‌ها و قوانین را پوشش می‌دهد.)

وضعیت bind را پس از شروع stack بررسی کنید: sudo ss -tlnp | grep 8080 باید 127.0.0.1:8080 را نشان دهد، نه 0.0.0.0:8080 یا *:8080.

Named volumes vs bind mounts

db-data:/var/lib/postgresql/data یک named volume است: Docker یک دایرکتوری زیر /var/lib/docker/volumes/ ایجاد و مدیریت می‌کند و آن را در container mount می‌کند. جایگزین آن، یک bind mount به نام ./data:/var/lib/postgresql/data است که مسیری را که خودتان روی host انتخاب کرده‌اید، map می‌کند.

تفاوت کاربردی آن‌ها: named volumes فقط برای کانتینرهایی که با داده‌ها درگیر هستند — و در رأس همه دیتابیس‌ها، زیرا Docker حجم را با مالکیت (ownership) مورد انتظار image مقداردهی اولیه می‌کند و مجوزهای فایل (file permissions) به درستی کار می‌کنند. bind mounts برای فایل‌هایی که از طریق host با آن‌ها کار می‌کنید — فایل‌های کانفیگ که با یک text editor ویرایش می‌کنید، یک کتابخانه media که با rsync وارد می‌کنید، یا هر چیزی که می‌خواهید مسیر آن مشخص باشد. شکست رایج در bind-mount، مسئله مالکیت است: container با UID 999 اجرا می‌شود، دایرکتوری host شما متعلق به UID 1000 است، و اپلیکیشن هنگام شروع با خطای permission denied در لاگ‌ها متوقف می‌شود. named volumeها باعث می‌شوند این نوع باگ تقریباً ناپدید شود، به قیمت اینکه داده‌ها در مسیری که توسط Docker مدیریت می‌شود ذخیره شوند — که در ادامه توضیح داده شده است.

environment and .env — keep secrets out of git

مقدار ${POSTGRES_PASSWORD} از shell شما خوانده نمی‌شود؛ Compose آن را از فایلی به نام .env که در کنار compose.yml قرار دارد، استخراج می‌کند. آن را ایجاد کنید:

cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignore

مقادیر واقعی را با openssl rand -hex 24 تولید کنید. استفاده از Hex به جای base64 عمدی است: این پسورد داخل رشته اتصال DATABASE_URL قرار می‌گیرد، و کاراکترهای /، + و = که base64 تولید می‌کند، باعث شکست در URL parsing می‌شوند — خطایی که به صورت error در احراز هویت ظاهر می‌شود، نه error سینتکسی، و باعث هدر رفتن زمان می‌شود. خط .gitignore باید قبل از اولین commit قرار گیرد: فایل compose برای انتشار و ورژن‌گذاری امن است، اما فایل .env هرگز امن نیست، و هر رازی که وارد تاریخچه git شده باشد، رازی است که باید آن را rotate کنید. اگر stack را با یک متغیر ناقص شروع کنید، Compose هشدار می‌دهد و با یک رشته خالی ادامه می‌دهد — که برای پسورد Postgres به معنای یک deployment خراب است:

WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.

دستور docker compose config فایل کاملاً استخراج شده را چاپ می‌کند — سریع‌ترین راه برای بررسی آنچه کانتینرها واقعاً دریافت خواهند کرد؛ به یاد داشته باشید که خروجی آن شامل اسرار شماست.

depends_on waits for nothing — unless you add a healthcheck

یک دستور ساده depends_on: [db] فقط ترتیب شروع را کنترل می‌کند: Compose ابتدا Postgres را اجرا می‌کند و لحظاتی بعد اپلیکیشن را، در حالی که هنوز چند ثانیه تا پذیرش کانکشن‌ها توسط Postgres باقی مانده است. اپلیکیشن به دیتابیس متصل می‌شود، شکست می‌خورد و بسته به کیفیت کدنویسی، کرش می‌کند یا تلاش مجدد می‌کند.

نسخه قابل اعتماد، همان چیزی است که فایل بالا از آن استفاده می‌کند: سرویس db یک healthcheck تعریف می‌کند (Postgres یک pg_isready برای همین منظور ارائه می‌دهد) و اپلیکیشن با استفاده از condition: service_healthy، از depends_on استفاده می‌کند. Compose دیتابیس را استارت می‌زند، هر 10 ثانیه وضعیت check را بررسی می‌کند و تنها زمانی Miniflux را استارت می‌زند که check با موفقیت انجام شود. اگر دیتابیس هرگز سالم نشود — به دلیل پسورد اشتباه یا volume خراب — اپلیکیشن هرگز شروع نمی‌شود و Compose به شما می‌گوید کدام وابستگی (dependency) شکست خورده است:

dependency failed to start: container miniflux-db-1 is unhealthy

این پیام شما را به سمت docker compose logs db هدایت می‌کند، جایی که خطای اصلی در آن نهفته است.

restart: unless-stopped

استفاده از restart: unless-stopped برای هر دو سرویس به این معناست که کانتینرها پس از کرش و پس از ریبوت VPS دوباره بالا می‌آیند، اما اگر شما آگاهانه docker compose stop را اجرا کرده باشید، پایین می‌مانند. جایگزین آن یعنی always، کانتینرها را حتی پس از توقف دستی نیز احیا می‌کند — که معمولاً هدف شما نیست. بدون یک restart policy، یک ریبوت ناشی از آپدیت kernel در ساعت 4 صبح، سرویس‌های شما را بدون اطلاع از دسترس خارج می‌کند تا زمانی که متوجه شوید.

دستورات روزانه

تمام کارهای روزانه شامل 5 دستور است که باید از دایرکتوری پروژه اجرا شوند.

docker compose up -d        # create and start; idempotent, recreates only what changed
docker compose ps           # status, ports, and health of this project's containers
docker compose logs -f miniflux   # follow one service's logs; --tail 100 for recent history
docker compose pull && docker compose up -d   # upgrade to the pinned tags
docker compose down         # stop and remove containers and the network

اجرای مکرر up -d بدون ریسک است؛ این دستور فایل را با وضعیت واقعی مقایسه می‌کند و فقط سرویس‌هایی را تغییر می‌دهد که پیکربندی یا image آن‌ها تغییر کرده باشد. جفت دستور upgrade، هر آنچه که تگ‌های ثابت شده (pinned tags) شما اکنون به آن‌ها اشاره می‌کنند را دریافت می‌کند: نسخه‌های patch زیر postgres:16-alpine؛ اما برای یک pin دقیق، تا زمانی که آن را ویرایش نکنید، تغییری ایجاد نمی‌شود — هدف همین است. پس از ارتقا، imageهای قدیمی انباشته می‌شوند؛ با استفاده از docker image prune -f فضای دیسک را آزاد کنید.

حالا دستور تخریبی را با احتیاط اجرا کنید: docker compose down ایمن است — کانتینرها و شبکه قابل جایگزینی هستند و داده‌های شما در volume قرار دارد. docker compose down -v volumeهای نام‌گذاری شده را نیز حذف می‌کند. این یعنی دیتابیس شما، بدون هیچ پیام تأییدی و بدون امکان بازگشت، فوراً پاک می‌شود. پرچم -v برای از بین بردن آزمایش‌ها (experiments) در نظر گرفته شده است؛ در استک‌هایی که حاوی داده‌های واقعی هستند، با آن مانند rm -rf رفتار کنید. در زیر /var/lib/docker/volumes/ هیچ سطل زباله‌ای وجود ندارد.

برای دسترسی به یک shell موقت در یک کانتینر در حال اجرا: docker compose exec db psql -U miniflux شما را مستقیماً به دیتابیس متصل می‌کند و docker compose exec miniflux sh یک shell در اپلیکیشن برای شما فراهم می‌کند.

محل ذخیره‌سازی واقعی داده‌ها

Volumeهای نام‌گذاری شده (Named volumes) پیشوند پروژه را دریافت می‌کنند؛ بنابراین db-data در دایرکتوری miniflux به miniflux_db-data تبدیل می‌شود:

docker volume ls
docker volume inspect miniflux_db-data

خروجی inspect شامل خطی است که اهمیت دارد:

"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"

آن دایرکتوری، دیتابیس است — مالکیت آن با root است، در فایل‌سیستم host قرار دارد و در صورت down، ارتقا (upgrade) و بازسازی (rebuild) کانتینرها باقی می‌ماند. این دایرکتوری دقیقاً همان چیزی است که بک‌آپ‌های شما باید از آن تهیه کنند.

پشتیبان‌گیری از یک volume نام‌گذاری شده

الگوی استاندارد، استفاده از یک container موقت است که volume را به صورت read-only در کنار یک دایرکتوری در host mount می‌کند و سپس از آن tar می‌گیرد:

docker run --rm \
  -v miniflux_db-data:/data:ro \
  -v "$PWD":/backup \
  alpine:3.22 tar czf /backup/miniflux-db-$(date +%F).tar.gz -C /data .

بدون نیاز به نصب هیچ نرم‌افزاری، هیچ فرآیندی در پس‌زمینه باقی نمی‌ماند و فرآیند بازیابی نیز دقیقاً معکوس عمل می‌کند — یعنی انتقال tar xzf به یک volume خالی و جدید با mountهای معکوس.

یک نکته مهم برای پایگاه‌های داده: tar گرفتن از دایرکتوری داده‌های یک Postgres در حال اجرا، می‌تواند وضعیتی را در میان عملیات نوشتن ثبت کند که باعث عدم راه‌اندازی صحیح در آینده می‌شود. یا باید برای مدت زمانی که tar انجام می‌شود، docker compose stop را اجرا کنید، یا بهتر است از یک dump منطقی استفاده کنید که ذاتاً سازگار است:

docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gz

گزینه -T باعث غیرفعال شدن pseudo-terminal می‌شود که Compose به صورت پیش‌فرض اختصاص می‌دهد — ارسال خروجی dump از طریق یک TTY می‌تواند باعث خرابی آن شود. یکی از این دستورات را در cron قرار دهید و نتیجه را از VPS خارج کنید؛ ذخیره کردن پشتیبان در همان دیسکی که داده‌ها در آن هستند، تنها یک کپی است، نه یک backup واقعی. راهنمای Nextcloud یک برنامه زمان‌بندی شده کامل را دقیقاً بر اساس همین دو الگو بنا کرده است.

Failure modes, with the strings you will see

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock — you are not in the docker group yet, or you are but the session predates it. id shows your effective groups; newgrp docker fixes the current shell, logging out and back in fixes all of them.

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — different problem: the daemon itself is down. sudo systemctl status docker and sudo journalctl -u docker -n 50 say why. On a VPS the classic cause is a full disk — df -h /var/lib/docker first.

Bind for 127.0.0.1:8080 failed: port is already allocated — another container already published that host port. docker ps shows which; a stale container from an experimental docker run weeks ago is the usual culprit. If docker ps is clean, a non-Docker process holds the port: sudo ss -tlnp | grep 8080 names it.

yaml: line 14: did not find expected key — an indentation error at or just above the line named. Compose files are YAML: two-space indentation, spaces only, and a tab character anywhere is fatal. docker compose config validates the file without starting anything, and running it after every edit is a cheap habit.

The ufw surprise prints no error at all, which is what makes it dangerous: the deploy works, ufw status looks right, and a port scan from outside finds your database anyway. Re-read the ports section above, check every ports: entry for a missing 127.0.0.1: prefix, and confirm from a different machine with curl http://your-vps-ip:8080 — connection refused is the answer you want.

From here, the Traefik guide turns this single stack into many apps behind one HTTPS entry point, and what's worth self-hosting in 2026 is the shopping list to run through it.

A game server such as a Minecraft server on a VPS is a friendly first Compose project to practise on.

FAQ

Why do I get "permission denied while trying to connect to the Docker daemon socket"?

Your user is not in the docker group, or was added after the current session began — membership only applies at login. Run sudo usermod -aG docker $USER, then newgrp docker or log out and back in, and confirm with id. The group grants root-equivalent access to the host, so only add users you would give sudo.

Does docker compose down delete my data?

Plain docker compose down does not — it removes containers and the project network; named volumes survive and the next up -d reattaches them. docker compose down -v is the destructive form: it deletes the named volumes, meaning your database, with no confirmation and no undo. Never run -v on a stack with real data unless you hold a verified backup.

What is the difference between docker-compose and docker compose?

docker-compose (hyphen) is Compose v1, a standalone Python binary that reached end of life in 2023 and should not be installed on new servers. docker compose (space) is Compose v2, a Go plugin for the Docker CLI, installed as docker-compose-plugin from Docker's apt repository. Commands and YAML are almost fully compatible, so when an old tutorial says docker-compose up, type docker compose up.

Why can I reach my Docker container from the internet even though ufw blocks the port?

Because Docker publishes ports with DNAT rules in iptables' PREROUTING chain, and the rewritten packets travel the FORWARD path through Docker's own chains — they never hit the INPUT chain where ufw's rules apply. ufw deny 8080 therefore does nothing to a published container port. Fix it at the source: publish to 127.0.0.1: and expose services through a reverse proxy instead.

Should I use a named volume or a bind mount?

Named volumes for data only the container touches — databases especially, since Docker sets the ownership the image expects and permissions just work. Bind mounts for files you also handle from the host: configs you edit, media you upload, anything whose path you want obvious. If a container fails at startup with permission denied on a bind mount, host-vs-container UID mismatch is the first thing to check.