Merapikan konfigurasi aplikasi: env vars sebagai satu sumber kebenaran, struktur Config bertipe dengan validation, plus graceful shutdown lewat signal handling agar server berhenti tanpa memutus request.

Sampai episode 15, DATABASE_URL di-expect langsung di main, dan JWT secret dari episode 13 masih hardcoded. Ini baik untuk prototipe, buruk untuk produksi. Episode ini merapikan semuanya: konfigurasi terpusat berbasis env vars yang tervalidasi saat start, plus graceful shutdown — server berhenti tanpa memutus request yang sedang berjalan.
Mengapa config layak episode sendiri? Karena kesalahan config — secret bocor ke repo, nilai salah, shutdown yang memutus koneksi WebSocket — adalah penyebab insiden produksi yang paling memalukan. Konfigurasi yang benar adalah fondasi dari deployment (episode 24) dan security (episode 18).
Prinsip utama: semua nilai yang berubah antar environment (dev/staging/prod) berasal dari environment variable, bukan dari konstanta di kode. Nilai yang sama di semua environment boleh tetap di kode — tetapi secret dan koneksi tidak boleh.
Untuk development, .env file dibaca saat start. Tambahkan dotenvy:
cargo add dotenvyuse dotenvy::dotenv;
fn main() {
dotenv().ok(); // memuat .env jika ada; abaikan jika tidak
// ...
}File .env:
# Salin ke .env untuk development. JANGAN commit .env.
DATABASE_URL=postgres://postgres:axum-dev@localhost:5432/axum_blog
JWT_SECRET=ganti-dengan-secret-panjang-acak-minimal-32-karakter
BIND_ADDR=127.0.0.1:3000
RUST_LOG=axum_api=debug,tower_http=debug
MAX_CONNECTIONS=10Warning
.env berisi secret dan tidak boleh masuk git. Komit .env.example (versi template tanpa secret) dan tambahkan .env ke .gitignore. Jangan pernah mengalihkan secret ke kode — sekalinya masuk git history, sulit dihapus.
Semua env vars dibaca dan divalidasi di satu tempat: struct Config. Ini membuat startup gagal cepat (fail fast) jika config salah:
use std::env;
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub jwt_secret: String,
pub bind_addr: String,
pub max_connections: u32,
}
impl Config {
pub fn from_env() -> Self {
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL wajib disetel");
let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET wajib disetel");
let bind_addr = env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:3000".into());
let max_connections = env::var("MAX_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
if jwt_secret.len() < 32 {
panic!("JWT_SECRET harus minimal 32 karakter");
}
Self {
database_url,
jwt_secret,
bind_addr,
max_connections,
}
}
}Pola ini penting:
.expect("... wajib disetel") — nilai yang wajib membuat aplikasi berhenti dengan pesan jelas saat start, bukan gagal misterius di tengah jalan..unwrap_or_else(...) — nilai opsional punya default yang didokumentasikan.Clone dan dimasukkan ke AppState, jadi handler mana pun bisa membacanya.Alternatif yang lebih ketat: crate figment/config untuk file config + env vars. Untuk scope series ini, struct manual + env vars sudah cukup dan lebih transparan.
Config menjadi bagian AppState (dari episode 7) dan JWT secret dibaca dari config, bukan konstanta:
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::PgPool,
pub config: Arc<Config>,
}
#[tokio::main]
async fn main() {
dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
.init();
let config = Config::from_env();
let db = sqlx::postgres::PgPoolOptions::new()
.max_connections(config.max_connections)
.connect(&config.database_url)
.await
.expect("gagal konek database");
let state = AppState {
db,
config: Arc::new(config.clone()),
};
// ... router dibangun dari state
let listener = tokio::net::TcpListener::bind(&config.bind_addr).await.unwrap();
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()).await.unwrap();
}Sekarang main tidak lagi berisi nilai acak — semuanya bermuara ke satu Config. JWT verification (episode 13) diubah memakai state.config.jwt_secret:
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
jsonwebtoken::decode::<Claims>(
token,
&jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
&jsonwebtoken::Validation::default(),
)
.map(|data| data.claims)
}axum::serve punya .with_graceful_shutdown(future) — ketika future selesai, server berhenti menerima request baru, menyelesaikan yang sedang berjalan, lalu tutup. Tanpa ini, proses di-terminate paksa dan request yang sedang diproses hilang.
use tokio::signal;
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("gagal mendengarkan Ctrl+C");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("gagal mendaftarkan signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => tracing::info!("Ctrl+C diterima, shutdown..."),
_ = terminate => tracing::info!("SIGTERM diterima, shutdown..."),
}
}Fungsi ini menunggu dua sinyal:
SIGINT (Ctrl+C) — dipakai saat development.SIGTERM — dikirim oleh container runtime (Docker/K8s) saat scale-down atau deploy. Ini sinyal yang harus ditangani untuk deployment (episode 24).Note
Kenapa signal terminate harus ada? Ketika Docker menghentikan container, ia mengirim SIGTERM lalu menunggu beberapa detik sebelum SIGKILL. Dengan graceful shutdown, request in-flight selesai dalam jendela itu — tanpanya, koneksi WebSocket terputus dan transaksi terpotong. Ini perbedaan antara "restart mulus" dan "crash".
Graceful shutdown juga saat yang tepat menutup sumber daya — terutama pool database:
let state = AppState { db, config: Arc::new(config) };
let state_for_shutdown = state.clone();
axum::serve(listener, app)
.with_graceful_shutdown(async move {
shutdown_signal().await;
// koneksi DB yang belum dipakai dikembalikan & ditutup
state_for_shutdown.db.close().await;
})
.await
.unwrap();PgPool::close() menutup koneksi idle; koneksi yang sedang dipinjam handler tetap berfungsi sampai selesai. Ini mencegah "zombie connection" yang menyumbat database saat restart cepat.
Uji bahwa semuanya bekerja:
# Config salah -> fail fast dengan pesan jelas
DATABASE_URL= cargo run
# Harus panic: "DATABASE_URL wajib disetel"
# Shutdown halus
cargo run &
sleep 2
kill -TERM %1
# Log: "SIGTERM diterima, shutdown..." lalu proses keluar bersihMelihat log shutdown muncul dan proses keluar dengan code 0 adalah tanda config + signal handling bekerja.
Pada episode 16 ini konfigurasi dan lifecycle aplikasi beres:
.env untuk dev, .env.example sebagai template.Config dengan fail-fast validation, dimasukkan ke AppState.with_graceful_shutdown menangani SIGINT & SIGTERM.Di episode 17 selanjutnya kita bikin aplikasi bisa diintip: logging & observability — tracing structured logs, TraceLayer, metrics Prometheus, dan OpenTelemetry untuk aplikasi yang siap dipantau produksi. Sampai jumpa di episode 17!