Cara Install Docker Compose di Ubuntu 24.04
Panduan instalasi Docker Engine dan Compose v2 di Ubuntu 24.04. Pelajari cara menulis compose.yml, solusi masalah ufw, serta cara backup volume dengan aman.
Apa yang Anda bangun
Docker Compose adalah fondasi untuk hampir semua hal lain di situs ini. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat — setiap panduan tersebut dimulai dengan instruksi "tulis file compose ini", dan halaman ini menjelaskan arti sebenarnya dari file tersebut. Anda akan menginstal Docker Engine dan plugin Compose v2 dari repositori apt resmi Docker di Ubuntu 24.04, lalu menjalankan stack dua layanan — Miniflux, sebuah pembaca RSS ringan, ditambah PostgreSQL — karena pasangan ini menerapkan setiap pola yang digunakan aplikasi besar: image yang dikunci, database dengan healthcheck, volume bernama, secrets dalam file .env, dan port yang hanya dipublikasikan ke localhost.
Proses instalasi memakan waktu lima menit. Bagian lain dari panduan ini membahas masalah yang sering muncul kemudian: grup docker yang memiliki hak akses root, port yang dipublikasikan melewati aturan ufw, dan satu flag pada docker compose down yang menghapus database tanpa konfirmasi.
Prasyarat: VPS KVM Ubuntu 24.04 baru, pengguna dengan hak akses sudo, dan RAM minimal 1 GB. Instalasi Docker yang sudah ada juga diperbolehkan — bagian pertama menjelaskan apa yang harus dihapus.
Instal dari repo Docker, bukan dari Ubuntu
Ada dua kesalahan yang harus dihindari sebelum menjalankan perintah pertama. Paket docker.io bawaan Ubuntu berfungsi, tetapi versinya tertinggal dari rilis Docker dan tidak memiliki struktur plugin yang diperlukan. Selain itu, biner docker-compose mandiri — yang menggunakan tanda hubung — adalah Compose v1: berbasis Python, sudah mencapai end-of-life sejak 2023, dan menjadi penyebab tutorial lama gagal. Compose saat ini adalah docker compose dengan spasi, sebuah plugin CLI, yang diinstal dari repositori yang sama dengan engine.
Jika salah satu komponen tersebut sudah terinstal di sistem, hapus terlebih dahulu — termasuk docker-compose-v2, paket plugin bawaan Ubuntu, agar semua komponen berasal dari satu repositori:
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runcPackage 'docker.io' is not installed, so not removed adalah output normal pada VPS baru. Kemudian tambahkan repositori Docker dan instal:
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-pluginVerifikasi ketiga lapisan tersebut:
docker --version
docker compose version
sudo docker run --rm hello-worldDua perintah pertama akan menampilkan string versi — Docker Compose version v2.x.x mengonfirmasi bahwa Anda memiliki plugin, bukan biner v1 yang sudah usang. Perintah hello-world harus diakhiri dengan Hello from Docker!. Paket ini mengaktifkan layanan saat boot; systemctl is-enabled docker akan menampilkan enabled.
Grup docker adalah root — pertimbangkan dengan matang
Saat ini setiap perintah docker memerlukan sudo, karena socket daemon di /var/run/docker.sock dimiliki oleh root dan grup docker. Tanpa keanggotaan grup, Anda akan mendapatkan error Docker yang paling sering dicari:
permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sockSolusi standar:
sudo usermod -aG docker $USERKeanggotaan grup berlaku saat login, sehingga error tetap muncul pada shell yang sedang berjalan. Jalankan newgrp docker untuk sesi ini, atau keluar dan masuk kembali; id seharusnya kemudian mencantumkan docker dalam grup Anda.
Bagian jujurnya adalah: keanggotaan grup docker adalah root pada host. Bukan "mirip root", bukan "hak akses tinggi" — tetapi root. Siapa pun dalam grup tersebut dapat menjalankan docker run --rm -it -v /:/host alpine chroot /host dan menguasai seluruh filesystem tanpa perlu kata sandi. Grup ini ada untuk kenyamanan, bukan untuk pembatasan (containment).
Mode rootless Docker adalah alternatif yang sebenarnya — daemon itu sendiri berjalan sebagai user tanpa hak istimewa Anda. Konsekuensinya: port di bawah 1024 memerlukan pengaturan tambahan, jaringan berjalan melalui shim userspace dengan overhead yang terukur, dan beberapa image tidak berfungsi dengan benar tanpa root asli. Pada VPS dengan satu admin di mana satu-satunya login sudah memiliki sudo, perubahan grup tidak mengubah apa pun secara praktis, dan inilah yang diasumsikan oleh setiap panduan di sini — jangan pernah memberikannya seolah-olah itu kurang dari sudo.
Anatomi file compose
Berikan setiap stack direktori sendiri — nama direktori tersebut menjadi nama proyek, yang menjadi awalan untuk container, network, dan volume:
sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/minifluxBuat compose.yml (nama modern; docker-compose.yml masih berfungsi). Lewati key version: yang lama — key tersebut sudah usang dan Compose akan memberikan peringatan jika menemukannya.
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:Setiap baris di atas adalah sebuah keputusan. Lakukan satu per satu.
Pin versi image — :latest ditambah pull adalah upgrade otomatis
postgres:16-alpine, bukan postgres:latest. Sebuah tag tidak bersifat statis: :latest akan merujuk ke apa pun yang baru saja diunggah oleh maintainer, setiap kali Anda melakukan pull. Gabungkan hal ini dengan kebiasaan upgrade rutin yang akan Anda pelajari — docker compose pull && docker compose up -d — dan :latest berarti lompatan versi mayor akan terjadi kapan pun upstream merilisnya, bukan saat Anda memilih. Pada PostgreSQL hal ini bukan sekadar hipotesis: lompatan mendadak dari 16 ke 17 akan menyebabkan container mengalami crash-loop karena direktori data yang tidak kompatibel, karena upgrade mayor Postgres memerlukan dump dan restore, bukan sekadar restart.
Pin setidaknya versi mayornya (postgres:16-alpine mengikuti rilis patch 16.x), dan pin aplikasi ke rilis eksak seperti miniflux/miniflux:2.2.9 — periksa halaman rilis proyek dan gunakan versi terbaru saat Anda menulis file tersebut. Upgrade kemudian menjadi pengeditan satu baris yang Anda lakukan dengan sengaja, yang terlihat di git diff.
Publish ke 127.0.0.1, karena Docker melewati ufw
"127.0.0.1:8080:8080" — alamat host, port host, port container. Kebanyakan tutorial menulis "8080:8080", yang merupakan singkatan dari 0.0.0.0:8080:8080: mendengarkan pada setiap interface, termasuk interface publik.
Inilah jebakannya, dan ini menjebak hampir semua orang. Docker mempublikasikan port dengan menulis aturan DNAT yang menulis ulang tujuan paket ke IP internal container sebelum penyaringan, sehingga paket mengambil jalur FORWARD dan tidak pernah menyentuh INPUT, tempat aturan ufw Anda berada. sudo ufw deny 8080 melaporkan sukses, ufw status menunjukkan port ditolak, namun layanan tetap merespons seluruh internet. Firewall Anda tidak rusak; ia dilewati secara desain. Mengapa Docker melewati ufw, dan cara menyaring trafik container yang sebenarnya menjelaskan mekanisme tersebut dan perbaikan DOCKER-USER untuk port yang harus tetap publik.
Kebiasaan yang membuat seluruh masalah ini hilang: ikat (bind) port yang dipublikasikan ke 127.0.0.1 kecuali Anda memiliki alasan khusus untuk tidak melakukannya, dan gunakan reverse proxy di depannya untuk apa pun yang harus menghadap dunia luar. Itulah yang dibangun oleh panduan reverse proxy Traefik sebagai langkah selanjutnya setelah halaman ini — satu container yang memiliki port 80 dan 443 dan mengarahkan ke semua hal lainnya melalui hostname, dengan TLS. (Berasal dari setup Traefik v2 lama? panduan migrasi Traefik v2 ke v3 mencakup perubahan nama dan aturan.)
Verifikasi binding setelah menjalankan stack: sudo ss -tlnp | grep 8080 harus menunjukkan 127.0.0.1:8080, bukan 0.0.0.0:8080 atau *:8080.
Named volumes vs bind mounts
db-data:/var/lib/postgresql/data adalah named volume: Docker membuat dan mengelola direktori di bawah /var/lib/docker/volumes/ dan memasangnya (mount) ke dalam container. Alternatifnya adalah bind mount, ./data:/var/lib/postgresql/data, yang memetakan jalur yang Anda pilih pada host.
Pembagian yang efektif dalam praktik: named volumes hanya untuk container yang menyentuh data — terutama database, karena Docker menginisialisasi volume dengan kepemilikan yang diharapkan oleh image sehingga izin file langsung berfungsi. Bind mounts untuk file yang Anda sentuh dari host — file konfigurasi yang Anda edit dengan text editor, pustaka media yang Anda rsync ke sana, apa pun yang jalurnya ingin Anda buat jelas. Kegagalan bind-mount yang klasik adalah kepemilikan: container berjalan sebagai UID 999, direktori host Anda dimiliki oleh UID 1000, dan aplikasi mati saat startup dengan permission denied di lognya. Named volumes membuat jenis bug tersebut hampir hilang, dengan konsekuensi data tersimpan di jalur yang dikelola Docker — dibahas di bawah.
environment dan .env — simpan rahasia di luar git
${POSTGRES_PASSWORD} tidak dibaca dari shell Anda; Compose mengisinya dari file bernama .env yang berada di samping compose.yml. Buatlah file tersebut:
cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignoreGenerate nilai asli dengan openssl rand -hex 24. Menggunakan hex, bukan base64, dengan sengaja: password ini masuk ke dalam string koneksi DATABASE_URL, dan karakter /, +, dan = yang dihasilkan base64 akan merusak parsing URL — kegagalan ini muncul sebagai error autentikasi, bukan error sintaksis, dan membuang-buang waktu. Baris .gitignore dimasukkan sebelum commit pertama: file compose aman untuk dipublikasikan dan di-versioning, file .env tidak pernah aman, dan rahasia yang sudah menyentuh riwayat git adalah rahasia yang harus Anda rotasi. Jika Anda menjalankan stack dengan variabel yang hilang, Compose akan memberikan peringatan keras dan melanjutkan dengan string kosong — yang bagi password Postgres berarti deployment yang rusak:
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.docker compose config mencetak file yang telah di-interpolasi sepenuhnya — cara tercepat untuk memeriksa apa yang sebenarnya akan diterima oleh container; ingatlah bahwa outputnya menyertakan rahasia Anda.
depends_on menunggu tidak ada apa pun — kecuali Anda menambahkan healthcheck
depends_on: [db] biasa hanya mengontrol urutan mulai: Compose meluncurkan Postgres terlebih dahulu dan aplikasi sesaat kemudian, sementara Postgres masih beberapa detik lagi sebelum bisa menerima koneksi. Aplikasi mencoba menghubungi database, gagal, dan crash atau mencoba lagi tergantung pada seberapa baik aplikasi tersebut ditulis.
Versi yang andal adalah apa yang digunakan file di atas: layanan db mendefinisikan healthcheck (Postgres menyediakan pg_isready khusus untuk hal ini), dan aplikasi mendeklarasikan depends_on dengan condition: service_healthy. Compose menjalankan database, memeriksa status setiap 10 detik, dan hanya menjalankan Miniflux setelah pemeriksaan berhasil. Jika database tidak pernah dalam kondisi sehat — password salah, volume korup — aplikasi tidak akan pernah berjalan dan Compose akan memberi tahu dependensi mana yang gagal:
dependency failed to start: container miniflux-db-1 is unhealthyPesan tersebut mengarahkan Anda ke docker compose logs db, tempat error yang sebenarnya berada.
restart: unless-stopped
restart: unless-stopped pada kedua layanan berarti container akan kembali setelah crash dan setelah reboot VPS, tetapi tetap mati jika Anda sengaja menjalankan docker compose stop. Alternatifnya always akan menghidupkan kembali container bahkan setelah dihentikan secara manual — jarang sekali ini yang Anda inginkan. Tanpa kebijakan restart, reboot akibat update kernel pada jam 4 pagi akan mematikan layanan Anda secara diam-diam sampai Anda menyadarinya.
Perintah harian
Semua aktivitas harian terdiri dari lima perintah, dijalankan dari direktori proyek.
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 aman untuk dijalankan berulang kali — perintah ini membandingkan file dengan kondisi aktual dan hanya memproses layanan yang konfigurasi atau imagenya berubah. Pasangan perintah upgrade mengambil apa pun yang ditunjuk oleh tag yang Anda kunci: rilis patch di bawah postgres:16-alpine, tidak ada perubahan untuk pin eksak sampai Anda mengeditnya — itulah tujuannya. Image lama akan menumpuk setelah upgrade; gunakan docker image prune -f untuk mengosongkan ruang disk.
Sekarang untuk perintah yang bersifat destruktif: docker compose down aman — container dan jaringan bersifat sementara, dan data Anda tersimpan di volume. docker compose down -v juga menghapus volume bernama. Itu adalah database Anda, hilang seketika, tanpa konfirmasi dan tanpa fitur undo. Flag -v digunakan untuk menghentikan eksperimen; pada stack yang menyimpan data asli, perlakukan perintah ini seperti Anda memperlakukan rm -rf. Tidak ada tempat sampah di bawah /var/lib/docker/volumes/.
Untuk shell sekali pakai di dalam container yang sedang berjalan: docker compose exec db psql -U miniflux akan membawa Anda ke database, dan docker compose exec miniflux sh akan memberikan Anda shell di dalam aplikasi.
Lokasi penyimpanan data Anda
Volume bernama mendapatkan awalan proyek, sehingga db-data di dalam direktori bernama miniflux menjadi miniflux_db-data:
docker volume ls
docker volume inspect miniflux_db-dataOutput inspect menyertakan baris yang penting:
"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"Direktori tersebut adalah database — dimiliki oleh root, berada di filesystem host, dan tetap ada setelah down, pembaruan, serta pembangunan ulang container. Direktori ini juga merupakan data yang harus ditangkap oleh cadangan (backup) Anda.
Mencadangkan named volume
Pola standarnya adalah menggunakan container sementara yang melakukan mount volume secara read-only ke direktori host, lalu melakukan 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 .Tanpa instalasi, tidak ada proses yang tetap berjalan, dan proses pemulihan adalah kebalikannya — tar xzf ke dalam volume kosong baru dengan mount yang dibalik.
Satu catatan untuk database: melakukan tar pada direktori data Postgres yang sedang berjalan dapat menangkap status saat penulisan sedang berlangsung, yang menyebabkan database gagal berjalan saat dimulai kembali. Gunakan docker compose stop selama beberapa detik saat proses tar berlangsung, atau — lebih baik — lakukan logical dump yang konsisten secara struktur:
docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gz-T menonaktifkan pseudo-terminal yang dialokasikan Compose secara default — mengalirkan output dump melalui TTY dapat merusak data. Masukkan salah satu perintah ini ke dalam cron dan salin hasilnya keluar dari VPS; cadangan pada disk yang sama dengan data yang dilindungi hanyalah salinan, bukan cadangan. Panduan Nextcloud menyusun rutinitas terjadwal lengkap menggunakan kedua pola ini.
Mode kegagalan, dengan string yang akan Anda lihat
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock — Anda belum masuk ke dalam grup docker, atau Anda sudah masuk tetapi sesi tersebut dibuat sebelum perubahan grup dilakukan. id menunjukkan grup efektif Anda; newgrp docker memperbaiki shell saat ini, keluar dan masuk kembali akan memperbaiki semuanya.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — masalah berbeda: daemon sedang mati. sudo systemctl status docker dan sudo journalctl -u docker -n 50 menjelaskan penyebabnya. Pada VPS, penyebab umum adalah disk penuh — periksa df -h /var/lib/docker terlebih dahulu.
Bind for 127.0.0.1:8080 failed: port is already allocated — kontainer lain sudah menggunakan port host tersebut. docker ps menunjukkan kontainer mana; kontainer lama dari eksperimen docker run beberapa minggu lalu biasanya menjadi penyebabnya. Jika docker ps bersih, maka proses non-Docker yang menggunakan port tersebut: sudo ss -tlnp | grep 8080 akan menyebutkan namanya.
yaml: line 14: did not find expected key — kesalahan indentasi pada atau tepat di atas baris yang disebutkan. File Compose adalah YAML: gunakan indentasi dua spasi, hanya gunakan spasi, dan penggunaan karakter tab di mana pun akan menyebabkan error fatal. docker compose config memvalidasi file tanpa menjalankan apa pun, dan menjalankannya setelah setiap pengeditan adalah kebiasaan yang baik.
The ufw surprise tidak menampilkan error sama sekali, hal inilah yang membuatnya berbahaya: deployment berhasil, ufw status terlihat benar, namun pemindaian port dari luar tetap dapat menemukan database Anda. Baca kembali bagian ports di atas, periksa setiap entri ports: untuk memastikan tidak ada awalan 127.0.0.1: yang hilang, dan konfirmasi dari mesin berbeda menggunakan curl http://your-vps-ip:8080 — jawaban yang Anda inginkan adalah connection refused.
Dari sini, panduan Traefik mengubah satu stack ini menjadi banyak aplikasi di balik satu titik masuk HTTPS, dan apa yang layak di-self-host pada 2026 adalah daftar aplikasi untuk dijalankan melaluinya.
Game server seperti server Minecraft pada VPS adalah proyek Compose pertama yang cocok untuk latihan.
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.