👨🏫 Before we start…
- Configurations can be stored in a variety of formats, such as
.xml,.json,.env,.yaml, and.tomlfiles, as well as systems likeetcd, AWS Parameter Store, and GCP Runtime Configurator. In this project, we will save the configurations in an.envfile and usedocker-composeto 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/envdecodeto read environment variables in bulk and populate them as a struct. We choosejoeshaw/envdecodefor 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_DEBUGandDB_DEBUGwill be utilized with the application logs and the GORM logs in the future steps.
2. update 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
localhostfor the database host in the previous article, we encountered themigrate up: dial tcp 127.0.0.1:3306: connect: connection refusederror with the./bin/migrate upcommand. But, with the current configurations, we should be able to run./bin/migrate upinside 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.
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
│
├── compose.yml
└── Dockerfile
👨🏫 What’s next…
In the next article, we’ll add the initial API routes to our application.