Ubuntu 24.04 Docker Compose 설치 및 설정 가이드
Ubuntu 24.04 VPS에 Docker Engine과 Compose v2를 설치하는 방법을 설명합니다. ufw 방화벽 포트 설정 오류를 방지하고, Miniflux와 PostgreSQL를 활용한 실전 compose.yml 작성 및 볼륨 백업법을 다룹니다.
구축 목표
Docker Compose는 이 사이트의 거의 모든 서비스의 기반입니다. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat — 모든 가이드는 "이 compose 파일을 작성하십시오"라는 문구로 시작하며, 이 페이지는 해당 파일의 실제 의미를 설명합니다. Ubuntu 24.04 환경에서 Docker의 공식 apt repository를 통해 Docker Engine과 Compose v2 plugin을 설치합니다. 그 후 Miniflux(소형 RSS reader)와 PostgreSQL로 구성된 2개 서비스 스택을 구축합니다. 이 조합은 이미지 고정(pinned images), healthcheck가 포함된 데이터베이스, named volume, .env 파일 내의 secrets, localhost로만 공개된 port 등 대규모 애플리케이션에서 사용하는 모든 패턴을 포함합니다.
설치에는 5분이 소요됩니다. 이후 내용은 나중에 문제가 될 수 있는 요소들을 다룹니다. root 권한을 가진 docker group, ufw 규칙을 우회하는 published ports, 그리고 확인 절차 없이 데이터베이스를 삭제하는 docker compose down의 특정 flag가 이에 해당합니다.
사전 요구 사항: 신규 설치된 Ubuntu 24.04 KVM VPS, sudo 권한을 가진 사용자, 1GB 이상의 RAM. 기존에 Docker가 설치되어 있어도 무방하며, 첫 번째 섹션에서 삭제 방법을 설명합니다.
Ubuntu 저장소가 아닌 Docker 저장소에서 설치하십시오
첫 번째 명령어를 실행하기 전에 두 가지 잘못된 설정을 피해야 합니다. Ubuntu 자체 docker.io 패키지는 작동하지만, Docker의 최신 릴리스보다 업데이트가 느리며 다른 도구들이 가정하는 플러그인 구조를 따르지 않습니다. 또한 하이픈이 포함된 독립형 docker-compose 바이너리는 Compose v1입니다. 이는 Python 기반이며 2023년에 지원이 종료되었으므로, 오래된 튜토리얼에서 오류가 발생하는 원인이 됩니다. 현재의 Compose는 공백이 포함된 docker compose이며, 엔진과 동일한 저장소에서 설치되는 CLI 플러그인입니다.
시스템에 이미 관련 패키지가 설치되어 있다면 먼저 삭제하십시오. 모든 패키지를 하나의 저장소에서 관리하기 위해 Ubuntu의 플러그인 패키지인 docker-compose-v2도 포함하여 삭제해야 합니다.
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc새 VPS의 일반적인 출력 결과는 Package 'docker.io' is not installed, so not removed입니다. 그 다음 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 group은 root 권한을 가집니다 — 주의하여 결정하십시오
현재 모든 docker 명령에는 sudo 권한이 필요합니다. /var/run/docker.sock에 있는 daemon socket의 소유자가 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그룹 멤버십은 로그인 시 적용됩니다. 따라서 현재 실행 중인 shell에서는 오류가 계속됩니다. 현재 세션에 적용하려면 newgrp docker을 실행하십시오. 또는 로그아웃 후 다시 로그인하십시오. 그러면 id 명령 결과에 docker이 포함되어야 합니다.
솔직하게 말씀드리겠습니다. docker 그룹의 멤버십은 호스트의 root 권한과 동일합니다. "root와 유사한" 권한이나 "승격된" 권한이 아니라, 실제 root 권한입니다. 해당 그룹의 구성원은 비밀번호 입력 없이도 docker run --rm -it -v /:/host alpine chroot /host를 실행하여 전체 filesystem의 소유권을 가질 수 있습니다. 이 그룹은 격리(containment)가 아닌 편의성을 위해 존재합니다.
Docker의 rootless mode가 실제 대안입니다. 이 모드에서는 daemon 자체가 권한이 없는 사용자 계정으로 실행됩니다. 이 방식의 비용은 다음과 같습니다: 1024 미만의 port를 사용하려면 추가 설정이 필요합니다. networking은 사용자 공간의 shim을 통해 실행되므로 성능 오버헤드가 발생합니다. 일부 image는 실제 root 권한이 없으면 정상적으로 작동하지 않습니다. 유일한 로그인 계정이 이미 sudo 권한을 가진 단일 관리자 VPS 환경에서는 그룹 설정이 실질적으로 아무런 차이를 만들지 않습니다. 이 가이드의 모든 내용은 이 점을 전제로 합니다. 다만, 이 권한을 sudo보다 낮은 권한인 것처럼 취급하여 부여하지 마십시오.
Anatomy of a compose file
각 stack마다 전용 디렉토리를 생성하십시오. 디렉토리 이름은 프로젝트 이름이 되며, 컨테이너, 네트워크, volume의 접두사로 사용됩니다.
sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/minifluxcompose.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:latest가 아닌 postgres:16-alpine을 사용하십시오. 태그는 고정되지 않습니다. :latest은 pull을 수행할 때마다 유지 관리자가 가장 최근에 푸시한 버전으로 다시 해석됩니다. 이를 곧 배우게 될 정기 업데이트 습관인 docker compose pull && docker compose up -d과 결합하면, :latest는 업스트림에서 배포할 때마다 메이저 버전이 자동으로 변경됨을 의미합니다. PostgreSQL의 경우 이는 가설이 아닙니다. 16에서 17으로 갑자기 버전이 올라가면 컨테이너는 호환되지 않는 데이터 디렉토리 때문에 crash-loop에 빠집니다. Postgres 메이저 업그레이드는 재시작이 아니라 dump와 restore를 필요로 하기 때문입니다.
최소한 메이저 버전은 고정하십시오 (postgres:16-alpine은 16.x 패치 릴리스를 따릅니다). 애플리케이션은 miniflux/miniflux:2.2.9와 같이 정확한 릴리스 버전으로 고정하십시오. 파일을 작성할 때 프로젝트의 releases 페이지를 확인하여 최신 버전을 사용하십시오. 이렇게 하면 업그레이드는 git diff에서 확인할 수 있는 의도적인 한 줄 수정 작업이 됩니다.
Publish to 127.0.0.1, because Docker walks around ufw
"127.0.0.1:8080:8080" — 호스트 주소, 호스트 포트, 컨테이너 포트 순입니다. 대부분의 튜토리얼은 "8080:8080"을 작성하며, 이는 모든 인터페이스(공용 인터페이스 포함)에서 리스닝하는 0.0.0.0:8080:8080의 축약형입니다.
여기에 함정이 있으며, 거의 모든 사용자가 한 번씩 겪게 됩니다. Docker는 DNAT 규칙을 작성하여 패킷의 목적지를 필터링 전에 컨테이너의 내부 IP로 재작성함으로써 포트를 게시합니다. 따라서 패킷은 FORWARD 경로를 타게 되며, ufw 규칙이 있는 INPUT에는 도달하지 않습니다. sudo ufw deny 8080은 성공을 보고하고, ufw status은 포트 거부를 보여주지만, 서비스는 여전히 인터넷 전체의 응답을 수행합니다. 방화벽이 고장 난 것이 아니라, 설계상 우회되고 있는 것입니다. Docker가 ufw를 우회하는 이유와 컨테이너 트래픽을 실제로 필터링하는 방법에서 이 메커니즘과 공용 포트를 유지해야 하는 경우를 위한 DOCKER-USER 해결 방법을 설명합니다.
이 문제를 완전히 해결하는 습관은 다음과 같습니다. 특별한 이유가 없다면 게시된 포트를 127.0.0.1에 바인드하고, 외부에 노출되어야 하는 모든 서비스 앞에는 리버스 프록시를 배치하십시오. 이것이 바로 Traefik 리버스 프록시 가이드에서 이 페이지 다음 단계로 구축하는 내용입니다. 80 및 443 포트를 점유하고 TLS를 사용하여 호스트 이름으로 다른 모든 곳에 라우팅하는 단일 컨테이너입니다. (이전 Traefik v2 설정을 사용 중입니까? Traefik v2에서 v3로의 마이그레이션 가이드에서 이름 변경 및 규칙 변경 사항을 다룹니다.)
stack을 시작한 후 바인드를 확인하십시오. sudo ss -tlnp | grep 8080은 0.0.0.0:8080 또는 *:8080이 아닌 127.0.0.1:8080을 표시해야 합니다.
Named volumes vs bind mounts
db-data:/var/lib/postgresql/data은 named volume입니다. Docker는 /var/lib/docker/volumes/ 아래에 디렉토리를 생성 및 관리하고 이를 컨테이너에 마운트합니다. 대안은 bind mount인 ./data:/var/lib/postgresql/data이며, 이는 호스트에서 선택한 경로를 매핑합니다.
실무에서 유효한 구분 방식은 다음과 같습니다. 컨테이너만 사용하는 데이터에는 named volume을 사용하십시오. 특히 데이터베이스가 이에 해당합니다. Docker가 이미지에서 기대하는 소유권으로 볼륨을 초기화하므로 파일 권한이 올바르게 작동합니다. 호스트에서 직접 수정하는 파일에는 bind mount를 사용하십시오. 텍스트 에디터로 편집하는 설정 파일, rsync로 넣는 미디어 라이브러리 등 경로가 명확해야 하는 모든 것이 해당됩니다. 전형적인 bind-mount 실패 사례는 소유권 문제입니다. 컨테이너는 UID 999로 실행되는데 호스트 디렉토리는 UID 1000 소유인 경우, 앱은 로그에 permission denied를 남기며 시작 시 종료됩니다. Named volume을 사용하면 데이터가 Docker 관리 경로에 저장된다는 점을 제외하면 이러한 종류의 버그는 대부분 사라집니다.
environment and .env — keep secrets out of git
${POSTGRES_PASSWORD}은 쉘에서 읽지 않습니다. Compose는 compose.yml 옆에 있는 .env이라는 이름의 파일에서 값을 보간합니다. 파일을 생성하십시오:
cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignoreopenssl rand -hex 24을 사용하여 실제 값을 생성하십시오. 의도적으로 base64가 아닌 Hex를 사용하십시오. 이 비밀번호는 DATABASE_URL 연결 문자열 내에 위치하며, base64가 생성하는 /, +, = 문자는 URL 파싱을 깨뜨립니다. 이 오류는 구문 오류가 아닌 인증 오류로 나타나며 많은 시간을 낭비하게 합니다. .gitignore 라인은 첫 번째 커밋 전에 작성해야 합니다. compose 파일은 공개 및 버전 관리가 안전하지만, .env 파일은 그렇지 않습니다. git 히스토리에 남은 비밀번호는 교체해야 하는 비밀번호입니다. 변수가 누락된 상태로 stack을 시작하면 Compose는 강력하게 경고를 표시하고 빈 문자열로 계속 진행합니다. Postgres 비밀번호의 경우 이는 배포 실패를 의미합니다:
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초마다 체크를 수행하며, 체크가 통과된 후에만 Miniflux를 시작합니다. 데이터베이스가 정상 상태가 되지 않으면(잘못된 비밀번호, 손상된 volume 등) 앱은 시작되지 않으며 Compose는 어떤 의존성 단계에서 실패했는지 알려줍니다:
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은 수동으로 중지한 후에도 컨테이너를 다시 실행하므로 의도와 다를 때가 많습니다. 재시작 정책이 없으면 새벽 4시에 커널 업데이트로 인한 재부팅이 발생했을 때 사용자가 인지할 때까지 서비스가 조용히 중단됩니다.
일일 작업 명령어
모든 일상적인 작업은 project directory에서 실행하는 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 networkup -d는 반복해서 실행해도 안전합니다. 이 명령어는 파일과 실제 상태를 비교하여, config 또는 image가 변경된 서비스만 수정합니다. upgrade pair는 현재 pinned tags가 가리키는 내용을 가져옵니다. postgres:16-alpine 미만의 patch releases는 가져오지만, exact pin은 사용자가 직접 수정하기 전까지는 아무것도 가져오지 않습니다. 이것이 이 방식의 목적입니다. 업그레이드 후에는 오래된 images가 쌓이게 됩니다. docker image prune -f을 사용하여 디스크 공간을 확보하십시오.
파괴적인 명령어를 주의하십시오. docker compose down은 안전합니다. container와 network는 소모품이며, 데이터는 volume에 저장됩니다. docker compose down -v은 named volumes까지 삭제합니다. 이 명령을 실행하면 데이터베이스가 즉시 삭제되며, 확인 절차나 실행 취소 기능이 없습니다. -v flag는 실험용 환경을 제거할 때 사용합니다. 실제 데이터가 있는 stack에서는 rm -rf과 동일한 방식으로 주의하여 사용하십시오. /var/lib/docker/volumes/에는 휴지통이 없습니다.
실행 중인 container 내부의 일회성 shell이 필요한 경우: docker compose exec db psql -U miniflux를 사용하면 database로 접속되며, docker compose exec miniflux sh을 사용하면 app의 shell로 접속됩니다.
데이터의 실제 저장 위치
Named volume에는 project prefix가 붙습니다. 따라서 miniflux 디렉토리 내의 db-data는 miniflux_db-data이 됩니다:
docker volume ls
docker volume inspect miniflux_db-datainspect 출력 결과에는 중요한 다음 라인이 포함됩니다:
"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"해당 디렉토리가 데이터베이스입니다. 이 디렉토리는 host filesystem에 존재하며 root 소유입니다. 또한 down, 업그레이드, container rebuild 후에도 데이터가 유지됩니다. 백업 시 반드시 이 디렉토리를 포함해야 합니다.
named volume 백업하기
표준 방식은 볼륨을 호스트 디렉터리에 read-only로 마운트하는 임시 컨테이너를 생성하여 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를 실행하면 됩니다.
데이터베이스 사용 시 주의사항이 있습니다. 실행 중인 Postgres 데이터 디렉터리를 tar로 압축하면 쓰기 작업 중인 상태가 캡처되어 정상적으로 시작되지 않을 수 있습니다. tar 작업이 진행되는 몇 초 동안 docker compose stop를 실행하거나, 더 권장되는 방법은 구조적으로 일관성이 보장되는 논리적 덤프를 생성하는 것입니다.
docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gz-T은 Compose가 기본적으로 할당하는 pseudo-terminal을 비활성화합니다. 덤프 출력을 TTY를 통해 파이핑하면 데이터가 손상될 수 있습니다. 이 명령 중 하나를 cron에 등록하고 결과물을 VPS 외부로 복사하십시오. 데이터가 저장된 디스크와 동일한 디스크에 백업을 저장하는 것은 복사본일 뿐 백업이 아닙니다. 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 — 아직 docker group에 포함되지 않았거나, 포함되었더라도 세션이 갱신되지 않은 상태입니다. id에서 현재 적용된 그룹을 확인할 수 있습니다. newgrp docker를 통해 현재 셸에 적용하며, 로그아웃 후 다시 로그인하면 모든 그룹이 적용됩니다.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — 다른 문제입니다. daemon이 중지된 상태입니다. sudo systemctl status docker 및 sudo journalctl -u docker -n 50에서 원인을 확인할 수 있습니다. VPS에서는 디스크 용량 부족이 일반적인 원인입니다. df -h /var/lib/docker을 먼저 확인하십시오.
Bind for 127.0.0.1:8080 failed: port is already allocated — 다른 컨테이너가 이미 해당 host port를 사용 중입니다. docker ps에서 사용 중인 컨테이너를 확인할 수 있습니다. 보통 몇 주 전 docker run 실험 중에 생성된 오래된 컨테이너가 원인입니다. docker ps 결과가 깨끗하다면 Docker 외부 프로세스가 포트를 점유한 것입니다. sudo ss -tlnp | grep 8080에서 해당 프로세스 이름을 확인할 수 있습니다.
yaml: line 14: did not find expected key — 지정된 라인 또는 바로 윗줄에 들여쓰기 오류가 있습니다. Compose 파일은 YAML 형식을 따릅니다. 두 칸 들여쓰기를 사용해야 하며, 공백만 허용됩니다. 탭(tab) 문자가 포함되면 오류가 발생합니다. docker compose config를 사용하면 컨테이너를 실행하지 않고도 파일의 유효성을 검사할 수 있습니다. 편집할 때마다 실행하는 습관을 갖는 것이 좋습니다.
The ufw surprise는 오류를 전혀 출력하지 않으므로 주의해야 합니다. 배포는 성공하고 ufw status 결과도 정상으로 보이지만, 외부에서 포트 스캔을 하면 데이터베이스가 노출될 수 있습니다. 위의 ports 섹션을 다시 읽고, 모든 ports: 항목에 127.0.0.1: 접두사가 누락되지 않았는지 확인하십시오. 그 다음 다른 기기에서 curl http://your-vps-ip:8080를 사용하여 연결을 테스트하십시오. connection refused가 출력되어야 정상입니다.
여기서 Traefik guide를 통해 단일 stack을 하나의 HTTPS 엔드포인트 뒤에 있는 여러 앱으로 확장할 수 있으며, what's worth self-hosting in 2026은 이를 통해 실행할 수 있는 목록을 제공합니다.
a Minecraft server on a VPS와 같은 게임 서버는 Compose를 연습하기 위한 좋은 첫 번째 프로젝트입니다.
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.