👨🏫 Before we start…
- Configurations can be stored in a variety of formats, such as
.xml
,.json
,.env
,.yaml
, and.toml
files, as well as systems likeetcd
, AWS Parameter Store, and GCP Runtime Configurator. In this project, we will save the configurations in an.env
file and usedocker-compose
to load them into the development environment.- Go standard library provides the
os.Getenv()
function to read each environment variable separately. But there are Go libraries such asspf13/viper
,kelseyhightower/envconfig
,caarlos0/env
, andjoeshaw/envdecode
to read environment variables in bulk and populate them as a struct. We choosejoeshaw/envdecode
for this project because it includes validations, zero-dependency, and ease of use.
Populate environment variables with Docker
💡 We use docker-compose
with the env_file
option to load the environment variables into the development environment. If you are using docker run
, you can use the --env-file
option with it.
1. Add .env
SERVER_PORT=8080
SERVER_TIMEOUT_READ=3s
SERVER_TIMEOUT_WRITE=5s
SERVER_TIMEOUT_IDLE=5s
SERVER_DEBUG=true
DB_HOST=db
DB_PORT=5432
DB_USER=myapp_user
DB_PASS=myapp_pass
DB_NAME=myapp_db
DB_DEBUG=true
💡
SERVER_DEBUG
andDB_DEBUG
will be utilized with the application logs and the GORM logs in the future steps.
2. update docker-compose.yml
app:
build: .
env_file: .env
ports:
- "8080:8080"
depends_on:
- db
Run docker-compose down
and docker-compose up
to populate the environment variables into the development environment.
Adding configs to the API
1. Download and install the packages and dependencies
go get github.com/joeshaw/envdecode
2. Add config/config.go
package config
import (
"log"
"time"
"github.com/joeshaw/envdecode"
)
type Conf struct {
Server ConfServer
DB ConfDB
}
type ConfServer struct {
Port int `env:"SERVER_PORT,required"`
TimeoutRead time.Duration `env:"SERVER_TIMEOUT_READ,required"`
TimeoutWrite time.Duration `env:"SERVER_TIMEOUT_WRITE,required"`
TimeoutIdle time.Duration `env:"SERVER_TIMEOUT_IDLE,required"`
Debug bool `env:"SERVER_DEBUG,required"`
}
type ConfDB struct {
Host string `env:"DB_HOST,required"`
Port int `env:"DB_PORT,required"`
Username string `env:"DB_USER,required"`
Password string `env:"DB_PASS,required"`
DBName string `env:"DB_NAME,required"`
Debug bool `env:"DB_DEBUG,required"`
}
func New() *Conf {
var c Conf
if err := envdecode.StrictDecode(&c); err != nil {
log.Fatalf("Failed to decode: %s", err)
}
return &c
}
func NewDB() *ConfDB {
var c ConfDB
if err := envdecode.StrictDecode(&c); err != nil {
log.Fatalf("Failed to decode: %s", err)
}
return &c
}
3. Update the cmd/api/main.go
to read the config from config
package main
import (
"fmt"
"io"
"log"
"net/http"
"myapp/config"
)
func main() {
c := config.New()
mux := http.NewServeMux()
mux.HandleFunc("/hello", hello)
s := &http.Server{
Addr: fmt.Sprintf(":%d", c.Server.Port),
Handler: mux,
ReadTimeout: c.Server.TimeoutRead,
WriteTimeout: c.Server.TimeoutWrite,
IdleTimeout: c.Server.TimeoutIdle,
}
log.Println("Starting server " + s.Addr)
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("Server startup failed")
}
}
4. Update the cmd/migrate/main.go
to read the config from config
package main
import (
"flag"
"fmt"
"log"
"os"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"myapp/config"
)
const (
dialect = "pgx"
fmtDBString = "host=%s user=%s password=%s dbname=%s port=%d sslmode=disable"
)
var (
flags = flag.NewFlagSet("migrate", flag.ExitOnError)
dir = flags.String("dir", "migrations", "directory with migration files")
)
func main() {
flags.Usage = usage
flags.Parse(os.Args[1:])
args := flags.Args()
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" {
flags.Usage()
return
}
command := args[0]
c := config.NewDB()
dbString := fmt.Sprintf(fmtDBString, c.Host, c.Username, c.Password, c.DBName, c.Port)
db, err := goose.OpenDBWithDriver(dialect, dbString)
if err != nil {
log.Fatalf(err.Error())
}
defer func() {
if err := db.Close(); err != nil {
log.Fatalf(err.Error())
}
}()
if err := goose.Run(command, db, *dir, args[1:]...); err != nil {
log.Fatalf("migrate %v: %v", command, err)
}
}
Run docker-compose down
, docker-compose build
and docker-compose up
to run the application with the recent changes.
5. Run go mod tidy
When we add a new package and use it, we have to run go mod tidy
to reorganize the dependencies in the go.mod
file.
Running migrations on the application startup
💡 Because we hardcoded
localhost
for the database host in the previous article, we encountered themigrate up: dial tcp 127.0.0.1:3306: connect: connection refused
error with the./bin/migrate up
command. But, with the current configurations, we should be able to run./bin/migrate up
inside the docker image with the correct host. So, let’s automate running migrations on application startup.
Usually, starting the database takes more time. So, we need to wait until the database is up before running database migrations. For this, we use docker-compose
db:
healthcheck
and app:
depends_on:db:condition
options.
version: '3.9'
services:
app:
build: .
env_file: .env
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy
command: [ "sh", "-c", "/myapp/bin/migrate up && /myapp/bin/api" ]
db:
image: postgres:alpine
environment:
- POSTGRES_DB=myapp_db
- POSTGRES_USER=myapp_user
- POSTGRES_PASSWORD=myapp_pass
ports:
- "5432:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U myapp_user -d myapp_db" ]
interval: 3s
timeout: 5s
retries: 5
restart: always
📁 Final project structure
myapp
├── cmd
│ ├── api
│ │ └── main.go
│ └── migrate
│ └── main.go
│
├── migrations
│ └── 00001_create_books_table.sql
│
├── config
│ └── config.go
│
├── .env
│
├── go.mod
├── go.sum
│
├── docker-compose.yml
└── Dockerfile
👨🏫 What’s next…
In the next article, we’ll add the initial API routes to our application.