Mengelola Environment Variables untuk Local Development di Go
11 min read

Mengelola Environment Variables untuk Local Development di Go

Hampir setiap aplikasi Go butuh konfigurasi — port server, kredensial database, API key, feature flag — dan hampir semua tim mulai dengan cara yang sama: os.Getenv ditebar di berbagai tempat di kode. Pendekatan ini berjalan baik selama aplikasi masih kecil, tapi begitu jumlah variabel konfigurasi bertambah dan aplikasi perlu berjalan di banyak environment (local, staging, production), masalah mulai muncul — env var yang lupa divalidasi, default yang tidak konsisten, atau config yang sulit ditest. Artikel ini membahas bagaimana Go menangani konfigurasi mulai dari yang paling dasar, lalu naik bertahap ke pola yang lebih scalable: precedence antar sumber config, mapping ke struct dengan reflection, validasi startup, hingga pertimbangan kapan butuh library seperti Viper dan secret manager di production.

Membaca Environment Variable di Go

Go menyediakan package bawaan os untuk membaca environment variables, tanpa dependency eksternal apapun. Ini titik awal yang paling sederhana, dan penting memahami perbedaan dua fungsi utamanya sebelum masuk ke pola yang lebih kompleks.

package main

import (
	"fmt"
	"os"
)

func main() {
	dbHost := os.Getenv("DB_HOST")
	fmt.Println("DB Host:", dbHost)
}

os.Getenv selalu mengembalikan string — kosong jika variabel tidak ada. Masalahnya, string kosong itu ambigu: apakah env var memang sengaja diset ke string kosong, atau memang tidak pernah diset sama sekali? Untuk kasus di mana perbedaan ini penting, Go menyediakan os.LookupEnv.

// ANTI-PATTERN: tidak bisa membedakan "tidak diset" dengan "diset string kosong"
dbHost := os.Getenv("DB_HOST")
if dbHost == "" {
	panic("DB_HOST is not set") // bisa salah; mungkin memang diset ""
}

// BENAR: LookupEnv membedakan eksplisit dengan boolean kedua
dbHost, ok := os.LookupEnv("DB_HOST")
if !ok {
	panic("DB_HOST is not set")
}

Gunakan os.LookupEnv untuk konfigurasi yang wajib ada, dan os.Getenv dengan fallback manual untuk konfigurasi opsional:

port := os.Getenv("PORT")
if port == "" {
	port = "8080"
}

File .env untuk Local Development

Go tidak membaca file .env secara native — ini sering jadi kejutan bagi developer yang sebelumnya terbiasa dengan Node.js atau Python di mana ekosistemnya lebih sering menyertakan dukungan ini secara default. Untuk local development, library yang paling umum dipakai adalah github.com/joho/godotenv.

go get github.com/joho/godotenv
# .env
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=secret
PORT=8080
package main

import (
	"log"
	"os"

	"github.com/joho/godotenv"
)

func main() {
	if err := godotenv.Load(); err != nil {
		log.Println(".env file not found, fallback to environment")
	}

	port := os.Getenv("PORT")
	log.Println("App running on port", port)
}

godotenv.Load() membaca file .env lalu menyuntikkan isinya ke environment variable proses saat ini — setelah itu, kode pemanggil tetap memakai os.Getenv seperti biasa, tanpa API khusus. Inilah kenapa pola ini terasa natural: .env hanya jadi sumber tambahan, bukan mekanisme config yang terpisah.

File .env hanya untuk local development. Jangan pernah deploy file .env ke production — kredensial sensitif yang tersimpan sebagai file biasa jauh lebih rentan dibanding env var yang di-inject langsung oleh platform deployment atau secret manager.

Tambahkan .env ke .gitignore sejak awal project, dan sediakan .env.example sebagai dokumentasi variabel apa saja yang dibutuhkan — tanpa nilai sensitif sungguhan:

# .env.example
DB_HOST=
DB_USER=
DB_PASSWORD=
PORT=8080

Precedence: Flag vs Env vs File vs Default

Begitu aplikasi punya lebih dari satu sumber konfigurasi — command-line flag, environment variable, file .env, dan nilai default — kamu butuh aturan yang jelas tentang sumber mana yang menang kalau nilai yang sama muncul di beberapa tempat sekaligus. Tanpa aturan eksplisit, debugging config yang “tidak sesuai harapan” jadi sangat membingungkan.

Konvensi yang umum dipakai di ekosistem Go (dan sejalan dengan 12-Factor App) adalah precedence berikut, dari paling tinggi ke paling rendah:

flowchart TD
    A[Command-line flag] -->|prioritas tertinggi| E[Nilai final]
    B[Environment variable] --> E
    C[File konfigurasi .env / .yaml] --> E
    D[Nilai default di kode] -->|prioritas terendah| E
    A -.override.-> B
    B -.override.-> C
    C -.override.-> D

Alasan urutan ini masuk akal: flag biasanya diset eksplisit oleh orang yang menjalankan binary saat itu juga, jadi paling spesifik terhadap konteks. Environment variable cocok untuk konfigurasi per-deployment (container, CI). File config cocok untuk default yang dibagikan ke seluruh tim. Default di kode adalah jaring pengaman terakhir.

func resolvePort(flagPort string) string {
	// 1. Flag eksplisit menang jika diset
	if flagPort != "" {
		return flagPort
	}
	// 2. Environment variable sebagai sumber berikutnya
	if envPort := os.Getenv("PORT"); envPort != "" {
		return envPort
	}
	// 3. Default sebagai jaring pengaman terakhir
	return "8080"
}
Tidak semua aplikasi butuh keempat layer ini sekaligus. CLI tool kecil mungkin cukup flag + default. Service yang jalan di container biasanya cukup env var + default. Tambahkan layer baru hanya kalau kebutuhannya nyata, bukan karena “mungkin nanti perlu”.

Memetakan Config ke Struct

Menebar os.Getenv di banyak tempat membuat konfigurasi sulit dilacak — tidak ada satu tempat yang menunjukkan semua variabel apa saja yang dibutuhkan aplikasi. Pola yang lebih rapi adalah memetakan semua env var ke satu struct di awal, lalu mengoper struct itu sebagai dependency ke bagian lain aplikasi.

type Config struct {
	Port       string
	DBHost     string
	DBUser     string
	DBPassword string
}

func LoadConfig() Config {
	return Config{
		Port:       getEnv("PORT", "8080"),
		DBHost:     os.Getenv("DB_HOST"),
		DBUser:     os.Getenv("DB_USER"),
		DBPassword: os.Getenv("DB_PASSWORD"),
	}
}

func getEnv(key, fallback string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return fallback
}

Pola ini sudah jauh lebih baik daripada os.Getenv tersebar di mana-mana, tapi masih punya satu masalah: setiap field baru di struct berarti satu baris kode manual baru di LoadConfig. Untuk struct dengan 5-10 field ini masih terkelola, tapi begitu jumlahnya puluhan — umum terjadi di aplikasi yang terintegrasi dengan banyak service eksternal — pola manual ini jadi repetitif dan rawan typo (misalnya nama field di struct tidak sinkron dengan nama env var yang dibaca).


Struct Tag dan Reflection untuk Config Otomatis

Solusi untuk masalah repetisi di atas adalah mendeskripsikan mapping env var langsung di struct tag, lalu memakai reflection untuk mengisi struct secara otomatis saat runtime. Ini adalah pola yang dipakai library populer seperti caarlos0/env, dan memahami cara kerjanya membantu kamu memutuskan kapan layak menulis sendiri versus kapan cukup pakai library yang sudah ada.

type Config struct {
	Port       string `env:"PORT" envDefault:"8080"`
	DBHost     string `env:"DB_HOST" envRequired:"true"`
	DBUser     string `env:"DB_USER" envRequired:"true"`
	DBPassword string `env:"DB_PASSWORD" envRequired:"true"`
}
flowchart TD
    A[Struct Config dengan tag] --> B[reflect.TypeOf untuk baca setiap field]
    B --> C{Tag env ada?}
    C -- Tidak --> D[Skip field]
    C -- Ya --> E[os.LookupEnv nama dari tag]
    E --> F{Ditemukan?}
    F -- Ya --> G[Set value ke field via reflect.Value]
    F -- Tidak --> H{envRequired true?}
    H -- Ya --> I[Return error]
    H -- Tidak --> J[Pakai envDefault]

Implementasi sederhana versi sendiri terlihat seperti ini, untuk memahami mekanismenya sebelum memutuskan pakai library:

package config

import (
	"fmt"
	"os"
	"reflect"
)

func Load(cfg interface{}) error {
	v := reflect.ValueOf(cfg).Elem()
	t := v.Type()

	for i := 0; i < t.NumField(); i++ {
		field := t.Field(i)
		envKey := field.Tag.Get("env")
		if envKey == "" {
			continue
		}

		value, ok := os.LookupEnv(envKey)
		if !ok {
			if def := field.Tag.Get("envDefault"); def != "" {
				value = def
			} else if field.Tag.Get("envRequired") == "true" {
				return fmt.Errorf("missing required env var: %s", envKey)
			}
		}

		v.Field(i).SetString(value)
	}
	return nil
}
// Pemakaian
var cfg Config
if err := config.Load(&cfg); err != nil {
	log.Fatal(err)
}
Reflection di Go punya overhead performa dibanding akses field langsung, dan error-nya baru terdeteksi saat runtime, bukan saat compile time. Untuk config yang hanya dibaca sekali di awal startup, trade-off ini biasanya tidak masalah — tapi jangan pakai pola reflection untuk sesuatu yang dipanggil berulang kali di hot path aplikasi.

Validasi Config saat Startup

Baik pakai pola manual maupun reflection, satu prinsip yang sama pentingnya adalah fail fast — aplikasi harus berhenti dengan error yang jelas di awal startup kalau konfigurasi penting tidak tersedia, bukan crash di tengah runtime saat request sedang diproses.

required := []string{
	"DB_HOST",
	"DB_USER",
	"DB_PASSWORD",
}

for _, key := range required {
	if os.Getenv(key) == "" {
		log.Fatalf("Missing required env var: %s", key)
	}
}

Untuk validasi yang lebih kompleks dari sekadar “ada atau tidak” — misalnya port harus berupa angka valid, atau URL harus punya format yang benar — tambahkan method Validate() pada struct config:

func (c Config) Validate() error {
	if _, err := strconv.Atoi(c.Port); err != nil {
		return fmt.Errorf("PORT harus berupa angka, dapat: %s", c.Port)
	}
	if c.DBHost == "" {
		return fmt.Errorf("DB_HOST wajib diisi")
	}
	return nil
}
cfg := LoadConfig()
if err := cfg.Validate(); err != nil {
	log.Fatalf("Config tidak valid: %v", err)
}

Dengan validasi eksplisit di startup, kamu memindahkan kemungkinan error dari “ditemukan user di production saat fitur dipakai” menjadi “ditemukan developer/CI saat deployment pertama kali jalan” — selisih waktu deteksi ini sering jadi pembeda antara insiden besar dan typo yang diperbaiki dalam hitungan menit.


Kapan Butuh Viper

Pola struct tag + reflection di atas sudah menyelesaikan banyak masalah, tapi ada skenario di mana menulis ulang mekanisme ini sendiri tidak lagi sepadan — terutama kalau aplikasi butuh membaca dari banyak format sekaligus (env, YAML, JSON, remote config server) atau butuh hot-reload saat file config berubah. Di situlah github.com/spf13/viper masuk sebagai pilihan populer di ekosistem Go.

package main

import (
	"log"

	"github.com/spf13/viper"
)

func main() {
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
	viper.AddConfigPath(".")
	viper.AutomaticEnv()

	viper.SetDefault("port", "8080")

	if err := viper.ReadInConfig(); err != nil {
		log.Println("Config file not found, relying on env vars and defaults")
	}

	port := viper.GetString("port")
	dbHost := viper.GetString("db_host")

	log.Println("Port:", port, "DB Host:", dbHost)
}

Viper otomatis menangani precedence antar sumber (flag, env, file, default) tanpa kamu perlu menulis logic itu sendiri, dan mendukung banyak format file config sekaligus.

Aspekos + struct manualReflection customViper
Dependency eksternalTidak adaTidak adaYa
Mendukung file YAML/JSONTidakTidak (perlu ditambah sendiri)Ya, native
Precedence otomatisManualManualOtomatis
Hot-reload configTidakTidakYa
Kompleksitas setupRendahSedangSedang–tinggi
Cocok untukAplikasi kecil, CLI toolAplikasi menengah tanpa mau nambah dependencyAplikasi besar, multi-environment, butuh fleksibilitas sumber config
Jangan pakai Viper hanya karena populer. Kalau kebutuhanmu cuma membaca belasan env var dengan default sederhana, pola struct tag + reflection (atau bahkan struct manual) sudah cukup dan lebih mudah dipahami tim tanpa perlu belajar API Viper.

Secret Manager untuk Production

Pola .env dan env var biasa cukup untuk local development, tapi di production — terutama untuk kredensial seperti database password, API key, atau private key — pendekatan ini punya keterbatasan. Env var yang di-inject lewat platform deployment masih bisa bocor lewat log proses, dump memory, atau akses tidak sengaja ke dashboard CI/CD. Untuk kebutuhan ini, secret manager seperti AWS Secrets Manager, HashiCorp Vault, atau Google Secret Manager menyediakan lapisan tambahan: enkripsi at-rest, audit log siapa mengakses secret kapan, dan rotasi otomatis tanpa redeploy aplikasi.

sequenceDiagram
    participant App as Aplikasi Go
    participant SM as Secret Manager
    participant DB as Database
    App->>SM: Request kredensial DB saat startup
    SM-->>App: Kredensial terenkripsi + audit log dicatat
    App->>DB: Connect menggunakan kredensial
    Note over SM: Rotasi kredensial berkala<br/>tanpa perlu redeploy aplikasi

Pola integrasinya umumnya: aplikasi membaca satu env var berisi referensi (misalnya nama secret atau ARN), lalu memanggil SDK secret manager untuk resolve nilai sebenarnya saat startup — bukan menyimpan kredensial sungguhan di env var.

// Konsep pendekatan, bukan implementasi lengkap SDK tertentu
secretName := os.Getenv("DB_PASSWORD_SECRET_NAME")
dbPassword, err := secretManagerClient.GetSecret(ctx, secretName)
if err != nil {
	log.Fatalf("Gagal mengambil secret: %v", err)
}
Jangan pernah commit kredensial sungguhan ke repository — baik lewat file .env, hard-code di kode, maupun config file YAML yang ter-track git. Sekali kredensial masuk ke git history, ia harus dianggap bocor permanen meski commit-nya kemudian dihapus, karena history git tetap bisa ditelusuri.

Testing Kode yang Bergantung pada Config

Kode yang langsung memanggil os.Getenv di tengah business logic sulit ditest, karena test jadi bergantung pada environment variable proses yang menjalankan test — dan environment ini bisa berbeda-beda di mesin developer maupun CI. Go 1.17 ke atas menyediakan t.Setenv khusus untuk masalah ini.

// ANTI-PATTERN: bergantung pada env var sungguhan saat test, perlu setup manual
func TestLoadConfig(t *testing.T) {
	os.Setenv("PORT", "9090")
	cfg := LoadConfig()
	if cfg.Port != "9090" {
		t.Errorf("expected 9090, got %s", cfg.Port)
	}
	os.Unsetenv("PORT") // mudah lupa, bisa bocor ke test lain
}

// BENAR: t.Setenv otomatis cleanup setelah test selesai
func TestLoadConfig(t *testing.T) {
	t.Setenv("PORT", "9090")
	cfg := LoadConfig()
	if cfg.Port != "9090" {
		t.Errorf("expected 9090, got %s", cfg.Port)
	}
	// tidak perlu Unsetenv manual, Go otomatis restore nilai sebelumnya
}

Pendekatan yang lebih baik lagi untuk testability jangka panjang adalah menginjeksi Config sebagai dependency, bukan membaca env var langsung di dalam fungsi yang diuji:

// ANTI-PATTERN: fungsi membaca env var langsung, sulit ditest dengan nilai berbeda
func ConnectDB() (*sql.DB, error) {
	host := os.Getenv("DB_HOST")
	return sql.Open("postgres", host)
}

// BENAR: Config diinjeksi sebagai parameter, mudah diuji dengan nilai apapun
func ConnectDB(cfg Config) (*sql.DB, error) {
	return sql.Open("postgres", cfg.DBHost)
}
func TestConnectDB(t *testing.T) {
	cfg := Config{DBHost: "localhost:5432"}
	db, err := ConnectDB(cfg)
	// tidak perlu menyentuh env var sama sekali
}

Dengan dependency injection seperti ini, test tidak lagi bergantung pada state global environment variable — setiap test bisa memberikan nilai config yang berbeda secara eksplisit dan terisolasi satu sama lain.


Tabel Ringkasan Strategi

KebutuhanPendekatan yang Direkomendasikan
Membaca satu-dua env var sederhanaos.Getenv / os.LookupEnv langsung
Local development tanpa export manualFile .env + godotenv
Banyak sumber config dengan prioritas jelasPrecedence eksplisit: flag > env > file > default
Puluhan env var dengan default & requiredStruct tag + reflection, atau library seperti caarlos0/env
Validasi konfigurasi sebelum aplikasi jalanMethod Validate() dipanggil saat startup, fail fast
Multi-format config (YAML/JSON/env) + hot-reloadViper
Kredensial sensitif di productionSecret manager (AWS Secrets Manager, Vault, dll)
Unit test fungsi yang bergantung pada env vart.Setenv, atau lebih baik lagi: injeksi Config sebagai parameter

Ringkasan

  • os.Getenv mengembalikan string kosong jika env var tidak ada; gunakan os.LookupEnv saat perbedaan “tidak diset” vs “diset kosong” itu penting.
  • File .env dengan godotenv memudahkan local development, tapi tidak boleh dipakai untuk menyimpan kredensial production.
  • Tetapkan precedence eksplisit antar sumber config — umumnya flag > environment variable > file > default — agar perilaku aplikasi tidak ambigu saat beberapa sumber bentrok.
  • Memetakan env var ke struct secara manual sudah cukup untuk aplikasi kecil, tapi struct tag + reflection lebih scalable untuk puluhan variabel.
  • Validasi config di startup (fail fast) memindahkan deteksi error dari saat runtime production ke saat deployment pertama kali jalan.
  • Viper sepadan dipakai kalau aplikasi butuh multi-format config atau hot-reload — untuk kebutuhan sederhana, pola manual atau reflection custom sudah cukup.
  • Kredensial sensitif di production sebaiknya dikelola lewat secret manager, bukan env var biasa, untuk audit log dan rotasi otomatis.
  • t.Setenv mempermudah testing tanpa lupa cleanup, tapi menginjeksi Config sebagai dependency parameter adalah pola paling testable jangka panjang.

Portofolio