Deploy app bare-metal
Catatan tentang cara deploy aplikasi (Python FastAPI, Rust Actix, Node frontend) langsung di server Ubuntu (contoh: marshall-3) tanpa Docker, memakai uv, systemd, dan Ansible. Fokus utama catatan ini adalah sisi sysadmin: layout direktori, model user dan permission, hardening service, dan otomasi.
Gambaran besar
Docker memberi tiga hal: isolasi, reproducibility, dan packaging. Di bare metal, ketiganya tetap bisa didapat dengan alat bawaan Linux:
| Kebutuhan | Di Docker | Di bare metal |
|---|---|---|
| Isolasi proses | namespace, cgroup | user terpisah per app + hardening systemd (juga pakai namespace & cgroup) |
| Dependency terkunci | image | lock file (uv.lock, Cargo.lock, package-lock.json) + venv per release
|
| Restart otomatis | restart: unless-stopped |
Restart=on-failure di unit systemd
|
| Log | docker logs |
journalctl -u nama-app
|
| Rollback | tag image lama | folder release lama + symlink |
| Deploy berulang | compose / CI | Ansible |
Kapan bare metal masuk akal: sedikit server, workload GPU, tim kecil, atau binary tunggal (Rust/Go). Kapan Docker lebih unggul: banyak server, scaling horizontal, atau dependency sistem yang bentrok antar app (misalnya versi CUDA berbeda per app, karena di bare metal semua app berbagi satu driver NVIDIA).
Layout direktori (FHS)
Setiap app memakai lokasi standar Filesystem Hierarchy Standard, supaya jelas mana kode, mana config, mana data:
| Path | Isi | Kenapa di sini |
|---|---|---|
/opt/<app>/releases/<versi>/ |
kode + .venv / target / dist |
/opt untuk software tambahan yang bukan dari paket OS
|
/opt/<app>/current |
symlink ke release aktif | service selalu menunjuk ke sini; ganti versi = ganti symlink |
/etc/<app>/<app>.env |
environment variable & secret | /etc untuk konfigurasi milik host, terpisah dari kode
|
/var/lib/<app>/ |
data, cache model, upload, cache uv/cargo/npm | /var/lib untuk state yang ditulis aplikasi saat jalan
|
/opt/uv/python/ |
interpreter Python bersama | dipakai semua app, bukan milik satu user |
/etc/systemd/system/<app>.service |
unit service | lokasi unit buatan admin (bukan dari paket) |
Pemisahan ini penting untuk permission: kode boleh read-only saat runtime, config hanya root yang boleh ubah, dan hanya folder data yang boleh ditulis aplikasi.
Model user & permission
Satu app, satu system user
sudo useradd --system --home-dir /var/lib/transcribe-api \
--shell /usr/sbin/nologin transcribe
--system: UID diambil dari rentang system (di Ubuntu di bawah 1000), tidak muncul di layar login, dan tidak dibuatkan mailbox atau aging password.--shell /usr/sbin/nologin: user tidak bisa login interaktif. Kalau proses app dibobol, penyerang tidak otomatis dapat shell login.- Kenapa per app? Kalau satu app bocor, app lain dan file-nya tidak ikut terbaca. Ini prinsip least privilege.
Perintah berguna:
id transcribe # UID, GID, dan group tambahan
getent passwd transcribe # home & shell
getent group | grep transcribe
sudo -u transcribe -H bash -c 'whoami; echo $HOME' # tes sebagai user app
Salah nama user? Rename tanpa bikin ulang:
sudo usermod -l transcribe -d /var/lib/transcribe-api appuser # ganti login & home
sudo groupmod -n transcribe appuser # ganti nama group
Karena UID/GID tidak berubah, ownership file otomatis ikut. usermod -d akan gagal kalau user sedang dipakai proses yang jalan, jadi stop service-nya dulu.
Error chown: invalid group: 'root:appuser' artinya group tersebut sudah tidak ada (sudah di-rename atau dihapus). Cek dengan getent group.
Tabel ownership & mode
| Path | Owner:Group | Mode | Artinya |
|---|---|---|---|
/opt/<app> |
root:root | 0755 |
hanya root yang bisa membuat/mengganti symlink current
|
/opt/<app>/releases |
app:app | 0755 |
user app meng-clone dan build release di sini |
/etc/<app> |
root:app | 0750 |
app boleh masuk & baca, user lain tidak bisa melihat isinya |
/etc/<app>/<app>.env |
root:app | 0640 |
root tulis, app baca, lainnya tidak bisa apa-apa |
/var/lib/<app> |
app:app | 0750 |
satu-satunya tempat app boleh menulis |
/var/lib/<app>/.ssh |
app:app | 0700 |
deploy key; SSH menolak key yang permission-nya terlalu terbuka |
/opt/uv/python |
root:root | a+rX |
semua user boleh baca & eksekusi interpreter, tidak boleh ubah |
Membaca mode oktal
Tiap digit = owner, group, others. Nilainya jumlah dari r=4, w=2, x=1.
| Mode | Owner | Group | Others | Dipakai untuk |
|---|---|---|---|---|
0755 |
rwx | r-x | r-x | folder publik, binary |
0750 |
rwx | r-x | --- | folder privat yang boleh dibaca group |
0700 |
rwx | --- | --- | folder sangat privat (.ssh)
|
0640 |
rw- | r-- | --- | file secret yang dibaca group |
0600 |
rw- | --- | --- | private key |
Untuk folder, bit x artinya "boleh masuk (traverse)", bukan "eksekusi". Tanpa x di folder induk, file di dalamnya tidak bisa diakses walaupun file-nya sendiri r.
chmod -R a+rX (huruf X besar) memberi x hanya ke folder dan file yang sudah executable. Ini cara aman membuka akses baca satu pohon direktori tanpa membuat semua file teks jadi executable.
Kenapa env file root:app 0640?
- App perlu membaca secret (DATABASE_URL, SECRET_KEY), tapi tidak perlu mengubahnya.
- Kalau app dibobol, penyerang tidak bisa mengganti config agar persisten.
- User lain di server tidak bisa membaca secret sama sekali.
- Jangan simpan secret di
.envdalam folder kode: gampang ikut ter-commit, dan ikut terbaca siapa pun yang bisa membaca kode.
Toolchain bersama & masalah home directory
uv bisa meng-install Python sendiri. Defaultnya ke ~/.local/share/uv/python. Masalahnya:
- venv hanya symlink ke interpreter. Kalau interpreter ada di home user A, user B tidak bisa memakainya.
- Unit systemd dengan
ProtectHome=truemembuat/home,/root, dan/run/usertidak terlihat. Interpreter di home = service gagal start.
Solusinya taruh di lokasi bersama dan buka akses baca:
export UV_PYTHON_INSTALL_DIR=/opt/uv/python
sudo -E uv python install 3.13
sudo chmod -R a+rX /opt/uv/python
Pola yang sama untuk Rust: RUSTUP_HOME=/opt/rust/rustup, CARGO_HOME=/opt/rust/cargo, lalu a+rX. Cache per app (registry cargo, cache uv, cache npm) ditaruh di /var/lib/<app> yang memang writable.
Akses GPU
Device NVIDIA ada di /dev/nvidia*:
ls -l /dev/nvidia*
Kalau mode-nya crw-rw-rw-, semua user bisa memakai GPU. Kalau hanya group video/render, tambahkan user app ke group tersebut (usermod -aG video transcribe) dan tulis SupplementaryGroups=video di unit. Opsi -a wajib; tanpa itu, group tambahan lain akan terhapus.
sudo yang sempit
Kalau deploy dijalankan sebagai user app dan perlu restart service, beri izin hanya untuk perintah itu:
echo 'transcribe ALL=(root) NOPASSWD: /usr/bin/systemctl restart transcribe-api, /usr/bin/systemctl restart transcribe-api-worker' \
| sudo tee /etc/sudoers.d/transcribe-api
sudo chmod 0440 /etc/sudoers.d/transcribe-api
sudo visudo -cf /etc/sudoers.d/transcribe-api # validasi sintaks!
- Selalu validasi dengan
visudo -c. File sudoers yang rusak bisa menguncisudountuk semua orang. - Path harus absolut dan persis sama. Di Ubuntu modern
/binadalah symlink ke/usr/bin, tapi sudo membandingkan string path, jadi cek dengancommand -v systemctl. - Jangan beri
NOPASSWD: ALLke user app. Itu sama saja memberi root.
Kalau deploy dijalankan lewat Ansible dengan user admin + become, aturan sudoers untuk user app tidak diperlukan sama sekali.
Di setup ini user app yang meng-clone dan build release, jadi kode bisa ditulis oleh user app. Ini sederhana tapi berarti app yang dibobol bisa mengubah kodenya sendiri. Alternatif yang lebih ketat:
- Buat user
deployerterpisah yang meng-clone & build semua release. - User app hanya punya akses baca + eksekusi ke
/opt/<app>. - Bonus: cache uv bisa dipakai bersama antar app, sehingga hardlink menghemat disk (berguna untuk paket besar seperti torch).
Pada level ini, ProtectSystem=strict sudah menahan sebagian besar risiko: saat runtime seluruh filesystem read-only bagi proses app, kecuali ReadWritePaths.
systemd
Anatomi unit
[Unit]
Description=Transcribe API
After=network-online.target redis-server.service
Wants=network-online.target redis-server.service
[Service]
Type=simple
User=transcribe
Group=transcribe
SupplementaryGroups=video
WorkingDirectory=/opt/transcribe-api/current
Environment=HOME=/var/lib/transcribe-api
Environment=PORT=6969
EnvironmentFile=-/etc/transcribe-api/transcribe-api.env
ExecStart=/opt/transcribe-api/current/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 6969
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/transcribe-api
[Install]
WantedBy=multi-user.target
Catatan per baris:
After=mengatur urutan,Wants=/Requires=mengatur ketergantungan. HanyaAfter=tidak membuat Redis ikut dijalankan.EnvironmentFile=-...: tanda-artinya "jangan gagal kalau file tidak ada". Variabel dari file meng-overrideEnvironment=.- Format env file systemd:
KEY=valueper baris, tanpaexport. ExecStartharus path absolut. Panggil binary venv langsung, bukanuv run, karenauv runbisa mencoba sync dependency saat start.- Worker sebaiknya
Restart=alwaysdanTimeoutStopSec=120supaya job yang sedang jalan sempat selesai saat restart (systemd mengirim SIGTERM dulu, baru SIGKILL setelah timeout).
Directive hardening
| Directive | Efek |
|---|---|
NoNewPrivileges=true |
proses dan turunannya tidak bisa naik hak akses (setuid, file capabilities tidak berlaku) |
ProtectSystem=strict |
seluruh filesystem read-only bagi service |
ReadWritePaths= |
pengecualian: path yang tetap boleh ditulis |
ProtectHome=true |
/home, /root, /run/user tidak terlihat
|
PrivateTmp=true |
/tmp privat per service, tidak berbagi dengan proses lain
|
Ukur tingkat keamanan unit:
systemd-analyze security transcribe-api
Output-nya memberi skor dan daftar directive lain yang bisa ditambahkan (misalnya ProtectKernelTunables, RestrictAddressFamilies). Tambahkan bertahap dan tes, karena hardening yang terlalu ketat bisa mematahkan app (misalnya akses GPU butuh device yang tidak diblokir PrivateDevices).
Operasional harian
sudo systemctl daemon-reload # wajib setelah edit file unit
sudo systemctl enable --now transcribe-api # aktif saat boot + jalan sekarang
systemctl status transcribe-api transcribe-api-worker
journalctl -u transcribe-api -u transcribe-api-worker -f # follow log
journalctl -u transcribe-api --since "1 hour ago" -p err # hanya error
systemctl cat transcribe-api # lihat unit final yang dipakai
Exit code systemd yang sering muncul
| Status | Penyebab umum |
|---|---|
status=203/EXEC |
path di ExecStart salah, file tidak executable, atau venv rusak (interpreter yang di-symlink tidak bisa diakses)
|
status=217/USER |
user di User= tidak ada
|
status=200/CHDIR |
WorkingDirectory tidak ada atau tidak bisa dimasuki user app
|
status=226/NAMESPACE |
path di ReadWritePaths belum dibuat
|
Permission denied di log app |
app menulis di luar ReadWritePaths (cache model, file upload); arahkan ke /var/lib/<app>
|
Pola release + symlink
/opt/transcribe-api/ ├── releases/ │ ├── 3f2a9c1d7e0b/ │ └── 9be41c0a22f1/ └── current -> releases/3f2a9c1d7e0b
Alur deploy:
- Clone versi baru ke
releases/<sha>. - Build (
uv sync --locked --no-dev,cargo build --release --locked,npm ci && npm run build). - Jalankan migrasi database kalau ada.
- Arahkan
currentke release baru, restart service. - Health check. Gagal → arahkan kembali ke release lama.
Soal atomik: ln -sfn sebenarnya menghapus symlink lalu membuat ulang, sehingga ada jeda sangat singkat tanpa current. Cara yang benar-benar atomik:
ln -s /opt/app/releases/NEW /opt/app/current.tmp
mv -T /opt/app/current.tmp /opt/app/current # rename(2) atomik
Venv tidak bisa dipindah. Path absolut tertanam di script dalam .venv/bin, jadi selalu build venv langsung di folder release, jangan build di tempat lain lalu di-copy.
Python dengan uv
uv sync --lockedgagal kalauuv.locktidak sinkron denganpyproject.toml. Ini yang diinginkan di produksi: server tidak pernah mengubah lock file.--no-devmelewati dependency development/test.UV_PYTHON_DOWNLOADS=nevermencegah uv diam-diam mengunduh Python saat build.- Update dependency dilakukan di laptop:
uv lock --upgrade, commit, lalu deploy. - Cache uv memakai hardlink ke venv selama berada di filesystem yang sama, sehingga deploy berikutnya cepat dan hemat disk.
Redis lokal
sudo apt install -y redis-server
sudo sed -i 's/^appendonly no/appendonly yes/' /etc/redis/redis.conf # persistence
grep -E '^(bind|appendonly)' /etc/redis/redis.conf # bind 127.0.0.1 -::1
sudo systemctl enable --now redis-server
redis-cli ping
Redis tanpa password wajib hanya listen di localhost. Di Docker, hostname-nya redis; di bare metal jadi redis://127.0.0.1:6379/0.
Jaringan
--host 127.0.0.1: app hanya bisa diakses dari server itu sendiri (cocok kalau nginx ada di server yang sama).--host 0.0.0.0: app bisa diakses dari luar. Wajib dibarengi firewall.- Kalau nginx publik ada di VM proxy terpisah, buka port app hanya untuk IP VM proxy:
sudo ufw allow OpenSSH
sudo ufw allow from 192.168.88.10 to any port 6969 proto tcp comment 'transcribe-api'
sudo ufw enable
sudo ufw status numbered
Ubuntu memakai AppArmor, bukan SELinux seperti AlmaLinux/RHEL. Untuk app di /opt biasanya tidak perlu konfigurasi tambahan.
Otomasi dengan Ansible
Kenapa Ansible
- Agentless: cukup SSH + Python di server.
- Idempotent: dijalankan berkali-kali hasilnya sama; hanya yang berubah yang diubah.
- Semua langkah manual di atas terdokumentasi sebagai kode, sehingga server bisa dibangun ulang kapan saja.
Struktur project
ansible-apps/
├── inventory.ini daftar server
├── apps.yml daftar app (repo, port, worker)
├── deploy.yml playbook utama
├── group_vars/all/main.yml setting global
├── group_vars/all/vault.yml secret terenkripsi
├── scripts/new-app.sh tambah app interaktif
└── roles/
├── base/ paket dasar
├── redis/
├── python_toolchain/ uv + Python di /opt/uv/python
├── rust_toolchain/ rustup di /opt/rust
├── node_toolchain/ NodeSource
└── app/ clone, deteksi tipe, build, systemd/nginx, health check, rollback
Entry app
apps:
- name: transcribe-api
repo: [email protected]:org/transcribe-api.git
version: main
port: 6969
user: transcribe
worker: true # bikin service transcribe-api-worker
redis: true
apt_packages: [ffmpeg]
extra_groups: [video]
health_path: /health
Tipe app dideteksi dari isi repo: pyproject.toml → Python, Cargo.toml → Rust, package.json → Node (Vite/React jadi static di-serve nginx, Next/Nuxt jadi service).
Perintah
ansible-playbook deploy.yml # semua app
ansible-playbook deploy.yml -e only=transcribe-api # satu app
ansible-playbook deploy.yml -e only=core-api -e rev=v1.2.0 # versi tertentu / rollback
ansible-playbook deploy.yml --syntax-check
ansible-vault encrypt group_vars/all/vault.yml
Konsep Ansible yang terkait permission
become: true: task dijalankan sebagai root lewat sudo.become_user: transcribe: task dijalankan sebagai user app (clone, build), sehingga file yang dihasilkan langsung dimiliki user yang benar tanpachownbelakangan.- Paket
aclwajib terpasang. Saatbecome_userke user non-root, Ansible menulis file modul sementara lalu memakai ACL (setfacl) agar user tujuan bisa membacanya. - Modul
filedenganowner,group,modemenggantikan rangkaianmkdir/chown/chmoddan idempotent. - Secret di ansible-vault, dirender ke env file lewat modul
templatedengan mode0640.
Deploy key untuk repo private
- Satu key per app, disimpan di
/var/lib/<app>/.ssh/id_ed25519(mode0600, folder0700). - Didaftarkan sebagai Deploy Key read-only di repo. Kalau server bocor, yang terekspos hanya akses baca ke satu repo, bukan akun GitHub pribadi.
StrictHostKeyChecking=accept-new: host key disimpan saat pertama kali, dan koneksi ditolak kalau host key berubah (proteksi MITM).
Troubleshooting
| Gejala | Penyebab | Solusi |
|---|---|---|
chown: invalid group |
group sudah di-rename/dihapus | getent group, pakai nama baru
|
useradd menghasilkan shell aneh |
sisa \ line-continuation ikut diketik dalam satu baris |
cek getent passwd, bikin ulang user
|
Service 203/EXEC padahal file ada |
venv menunjuk ke Python di home user lain | install Python di /opt/uv/python, buat ulang venv
|
| Model Whisper gagal diunduh | cache di luar ReadWritePaths |
set WHISPER_CACHE_DIR / HF_HOME ke /var/lib/<app>
|
libcudnn / libcublas not found |
di Docker library datang dari image | install paket pip nvidia-cudnn-cu12 / nvidia-cublas-cu12 atau CUDA toolkit di host
|
| Worker restart terus | error di job pertama atau Redis belum jalan | journalctl -u app-worker -n 100, redis-cli ping
|
Ansible gagal become_user |
paket acl belum terpasang |
apt install acl
|
Checklist server baru
- [ ] User admin pakai SSH key, login password dimatikan
- [ ]
ufwaktif, hanya SSH + port yang perlu - [ ] Driver NVIDIA terpasang,
nvidia-smijalan (kalau ada GPU) - [ ]
unattended-upgradesaktif untuk patch keamanan - [ ] Jalankan
ansible-playbook deploy.yml - [ ]
systemd-analyze security <app>untuk tiap service - [ ] Backup data di
/var/lib/<app>dan database, tes restore