Docker deploy
Tags: go, docker
Published: 2021-06-24 18:44:54

Our server is finished and almost ready for deploy, which will be done using Docker. Notice that i said it's *almost* ready, so let's see what is missing. This whole time we used React development server which is listening on port 3000 and redirecting all requests to our backend on port 8080. That makes sense for development since it makes it easier to develop frontend and backend at the same time, as well as debugging frontend React app. But in production we don't need that and it makes much more sense to have only our backend server running and to serve static frontend files to client. So instead of starting React development server using `npm start` command, we will build optimized frontend files for production, using command `npm run build` inside of `assets/` directory. This will create new directory `assets/build/` along with all files needed for production. Now we also have to instruct our backend where to find these files to be able to serve them. That is simply done using command `router.Use(static.Serve("/", static.LocalFile("./assets/build", true)))`. Of course, we want to do that only if server is started in `prod` environment, so we need to slightly update few files.

First, we will update `Parse()` function in `internal/cli/cli.go` to return environment value as a string:

```go
func Parse() string {
  flag.Usage = usage
  env := flag.String("env", "dev", `Sets run environment. Possible values are "dev" and "prod"`)
  flag.Parse()
  logging.ConfigureLogger(*env)
  if *env == "prod" {
    logging.SetGinLogToFile()
  }
  return *env
}
```

Then we will update `Config` struct `NewConfig()` function to be able to receive and set environment value:

```go
type Config struct {
  Host       string
  Port       string
  DbHost     string
  DbPort     string
  DbName     string
  DbUser     string
  DbPassword string
  JwtSecret  string
  Env        string
}

func NewConfig(env string) Config {
  ...
  return Config{
    Host:       host,
    Port:       port,
    DbHost:     dbHost,
    DbPort:     dbPort,
    DbName:     dbName,
    DbUser:     dbUser,
    DbPassword: dbPassword,
    JwtSecret:  jwtSecret,
    Env:        env,
  }
}
```

Now we can update `internal/cli/main.go` to receive env value form CLI, and send it to new configuration creation which will be used for starting server:

```go
func main() {
  env := cli.Parse()
  server.Start(conf.NewConfig(env))
}
```

Next thing we have to do is update router to be able to receive configuration argument, and set it to serve static files if started in production mode:

```go
package server

import (
  "net/http"
  "rgb/internal/conf"
  "rgb/internal/store"

  "github.com/gin-contrib/static"
  "github.com/gin-gonic/gin"
)

func setRouter(cfg conf.Config) *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can't be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Serve static files to frontend if server is started in production environment
  if cfg.Env == "prod" {
    router.Use(static.Serve("/", static.LocalFile("./assets/build", true)))
  }

  // Create API route group
  api := router.Group("/api")
  api.Use(customErrors)
  {
    api.POST("/signup", gin.Bind(store.User{}), signUp)
    api.POST("/signin", gin.Bind(store.User{}), signIn)
  }

  authorized := api.Group("/")
  authorized.Use(authorization)
  {
    authorized.GET("/posts", indexPosts)
    authorized.POST("/posts", gin.Bind(store.Post{}), createPost)
    authorized.PUT("/posts", gin.Bind(store.Post{}), updatePost)
    authorized.DELETE("/posts/:id", deletePost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}
```

Last line needing update is in `migrations/main.go` file. Just change

```go
store.SetDBConnection(database.NewDBOptions(conf.NewConfig()))
```

to

```go
store.SetDBConnection(database.NewDBOptions(conf.NewConfig("dev")))
```

Actually, that's not last thing. You will also have to update all tests that use configuration and router setup, but that's entirely up to you and it's dependent on what tests you have implemented.

Now everything is ready for Docker deploy. Docker is not in the scope of this guide, so i will not go into details about `Dockerfile, .dockerignore and docker-compose.yml` contents.

First we will create `.dockerignore` file in project root directory:

```bash
# This file
.dockerignore

# Git files
.git/
.gitignore

# VS Code config dir
.vscode/

# Docker configuration files
docker/

# Assets dependencies and built files
assets/build/
assets/node_modules/

# Log files
logs/

# Built binary
cmd/rgb/rgb

# ENV file
.env

# Readme file
README.md
```

Now create new directory `docker/` with two files, `Dockerfile and docker-compose.yml`. Content of `Dockerfile` will be:

```dockerfile
FROM node:16 AS frontendBuilder

# set app work dir
WORKDIR /rgb

# copy assets files to the container
COPY assets/ .

# set assets/ as work dir to build frontend static files
WORKDIR /rgb/assets
RUN npm install
RUN npm run build

FROM golang:1.16.3 AS backendBuilder

# set app work dir
WORKDIR /go/src/rgb

# copy all files to the container
COPY . .

# build app executable
RUN CGO_ENABLED=0 GOOS=linux go build -o cmd/rgb/rgb cmd/rgb/main.go

# build migrations executable
RUN CGO_ENABLED=0 GOOS=linux go build -o migrations/migrations migrations/*.go

FROM alpine:3.14

# Create a group and user deploy
RUN addgroup -S deploy && adduser -S deploy -G deploy

ARG ROOT_DIR=/home/deploy/rgb

WORKDIR ${ROOT_DIR}

RUN chown deploy:deploy ${ROOT_DIR}

# copy static assets file from frontend build
COPY --from=frontendBuilder --chown=deploy:deploy /rgb/build ./assets/build

# copy app and migrations executables from backend builder
COPY --from=backendBuilder --chown=deploy:deploy /go/src/rgb/migrations/migrations ./migrations/
COPY --from=backendBuilder --chown=deploy:deploy /go/src/rgb/cmd/rgb/rgb .

# set user deploy as current user
USER deploy

# start app
CMD [ "./rgb", "-env", "prod" ]
```

And content of `docker-compose.yml` is:

```yml
version: "3"
services:
  rgb:
    image: kramat/rgb
    env_file:
      - ../.env
    environment:
      RGB_DB_HOST: db
    depends_on:
      - db
    ports:
      - ${RGB_PORT}:${RGB_PORT}
  db:
    image: postgres
    environment:
      POSTGRES_USER: ${RGB_DB_USER}
      POSTGRES_PASSWORD: ${RGB_DB_PASSWORD}
      POSTGRES_DB: ${RGB_DB_NAME}
    ports:
      - ${RGB_DB_PORT}:${RGB_DB_PORT}
    volumes:
      - postgresql:/var/lib/postgresql/rgb
      - postgresql_data:/var/lib/postgresql/rgb/data
volumes:
  postgresql: {}
  postgresql_data: {}
```

All files required for Docker deploy are now ready, so let's see how to build Docker image and deploy it. First we will pull `postgres` image from official Docker containers repository:

```bash
docker pull postgres
```

Next step is to build `rgb` image. Inside of project root directory run (change `DOCKER_ID` with your own docker ID):

```go
docker build -t DOCKER_ID/rgb -f docker/Dockerfile .
```

To create `rgb` and `db` containers with resources, run:

```bash
cd docker/
docker-compose up -d
```

That will start both containers, and you can check their status by running `docker ps`. Finally, we need to run migrations. Open shell in `rgb` container by running:

```bash
docker-compose run --rm rgb sh
```

Inside of container we are able to run migrations same as before:

```bash
cd migrations/
./migrations init
./migrations up
```

And we are finished. You can open `localhost:8080` in your browser to check that everything is working as it should, which means you should be able to create account and add new post:

![Docker deploy finished](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/al4dpxh2v8zpu2iyfwrx.png)

It was quite long for a simple web app, but we have completed this guide. Hopefully it was useful to some of you. If you have any questions, remarks, or you have found any issues, feel free to contact me in comments. Good luck to everyone and happy coding :)