Improve your local web app development with Docker

Published

Have you ever had a situation where there was a bug in some deployed application, but the simple act of running that application locally made the bug impossible to find? It could’ve been that there was no local infrastructure (e.g. databases) to back the running services. Or port-configuration was hard-coded into the application (so it collided with other ports on your host machine; people love to hard-code to port 8000, 80 or 8080). Or, scarier still, the application was never actually run locally because it was deemed too difficult in the first place (in which case, you wonder how the deployed application worked at all)?

In an age where we have Docker to effectively ship the containerized machines that our applications run on, there really isn’t an excuse for making local development a more consistent experience. If there is greater confidence in running code locally, it becomes quicker to spot and fix bugs. It becomes quicker to onboard new developers and make them productive with the codebase. It allows you to run the application alongside other applications more easily (which is crucial if you are on a team that is in charge of multiple microservices).
So in this article, we’re going to go over how to set up a local, containerized environment for us to run a simple application stack.

Goals

The application is going to be an HTTP server that simply says “Hello, world”, albeit with a counter to say how many times a visitor arrived at the site. We will build this application in Go, and use Redis as external storage for the counter.
Crucially, we want the following things out of the setup:

  • Developers should only need docker and docker-compose installed on the host machine to run this application, not go or redis
  • However, developers that want to opt-out of docker should still be able to run the web service entirely on their host machine
  • Local ports should not cause interference with existing apps. We are not going to hard-code these infrastructure details - it should be easy to re-configure
  • Developers should be able to get into the container and start running commands as though they were the host. This will encourage developers to do their development in the container as opposed to outside, where possible.

So install Docker and Docker Compose, create a directory for your project and let’s get started!

Setting up the initial container

The docker-compose file will initially just have a go image and mount our entire local directory. This will allow you to work within the container with all of the application’s source files.

docker-compose.yaml

version: '3'

services:
  app:
    working_dir: /app
    stdin_open: true
    image: golang:1.14-alpine
    volumes:
      - ./:/app

I’ve picked an alpine build for the go image because of how small the Alpine images are. It should make the initial builds faster than using larger Linux distributions (and often you won’t need the functionality in said distributions for development or deployment either).

stdin_open is set to true so that we can run

$ docker-compose exec app [command]

to execute commands into the container when it is running.

Run:

$ docker-compose up -d

to start the container in detached mode. You can then enter the go container with:

$ docker-compose run exec app sh
/app #

And you’re in there, capable of running all your Linux commands inside the container!

The app - “Hello, world” revisited

Let us create a simple app - it will just expose a root / HTTP endpoint which writes Hello, world! as a response.

main.go

package main

import (
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello, world!"))
	})

	panic(http.ListenAndServe(":8000", nil))
}

The service is exposed on port 8000 for now (we will change this later). We now want to make this exposed in our docker-compose configuration:

docker-compose.yaml

version: '3'

services:
  app:
    working_dir: /app
    stdin_open: true
    image: golang:1.14-alpine
    volumes:
      - ./:/app
    ports:
      - 8000:8000

Now run the following commands:

$ docker-compose up -d
$ docker-compose exec app go run main.go

and visit http://localhost:8000 to see the message Hello, world!. If you see it, congratulations: the app is now containerized! You can stop the container with:

$ docker-compose down

This is a good starting point, but it can be improved on. We want to make the ports configurable for both the host and the container: in a world of microservices development, developers could be running multiple docker containers and apps on various ports, and we want to make this as easy as possible to change.

A local environment

Create a .env file. It will house environment variables that will allow developers to easily change certain configuration details for the container. This is a file that will be in .gitignore for the project repository - we don’t want to expose potential secret credentials into version control!

.env

HTTP_PORT=8000

This will allow us to use the file in the docker-compose configuration:

docker-compose.yaml

version: '3'

services:
  app:
    working_dir: /app
    stdin_open: true
    image: golang:1.14-alpine
    volumes:
      - ./:/app
    env_file:
      - .env
    ports:
      - ${HTTP_PORT}:8000

As you can see, env_file is configured to point to the local .env file, and we are using the defined HTTP_PORT variable in said file to define the exposed host port (note that it is still pointing to the container’s 8000 port).

Run the up commands again:

$ docker-compose up -d
$ docker-compose exec app go run main.go

And the app should be available on port 8000. Bring the container down, and modify the HTTP_PORT variable to another open port, then visit http://localhost:<HTTP_PORT>. It should now be available there!

Now developers can configure the local ports easily in the container. But what about the initial hard-coding to port 8000? That hard-coding into main.go and docker-compose.yaml is what allowed the application to still be exposed to use, but it forces developers who aren’t using docker to use this port.

While we want to encourage developers to build within the container, we don’t want to unnecessarily hamper them in this way if it can be avoided. With a small improvement we can get the best of both worlds and allow both container-using developers and host-using developers to configure the ports as they see fit.

In the app, use the os package to get the environment variable HTTP_PORT:

main.go

package main

import (
	"net/http"
	"os"
	"log"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello world!"))
	})

	port := os.Getenv("HTTP_PORT")

	log.Printf("listening on port %s", port)

	panic(http.ListenAndServe(":" + port, nil))
}

I have added logging just so you can see that the correct port is being used as well. We then change the docker-compose configuration so that the container port also uses HTTP_PORT:

docker-compose.yaml

version: '3'

services:
  app:
    working_dir: /app
    stdin_open: true
    image: golang:1.14-alpine
    volumes:
      - ./:/app
    env_file:
      - .env
    ports:
      - ${HTTP_PORT}:${HTTP_PORT}

Now if you run the app, be it in the container or on the host, you should be able to use your local .env file to configure the port! If you’re running it directly from the host, source the .env file before running the app:

$ source .env && go run main.go

Storage

At the beginning I said that we would use Redis in this app. Redis is great as a simple key-value store which we can use for demonstrating how to communicate with a database within our Docker container setup.

We will add the Redis configuration to our docker-compose file:

docker-compose.yaml

version: '3'

services:
  app:
    working_dir: /app
    stdin_open: true
    image: golang:1.14-alpine
    volumes:
      - ./:/app
    env_file:
      - .env
    ports:
      - ${HTTP_PORT}:${HTTP_PORT}

  redis:
    command: redis-server --port ${REDIS_PORT}
    image: redis:6.0-alpine
    env_file:
      - .env
    ports:
      - ${REDIS_PORT}:${REDIS_PORT}

The container is named redis, and it will simply start redis-server on the port we define in the .env file. To the .env file, add the following variables:

.env

REDIS_PORT=8002
REDIS_HOST=redis

REDIS_HOST here is set to the same name as the container. This is because within the containerized network infrastructure, Docker aliases the Redis container to have that hostname, so we can use this instead of trying to figure out where on the host machine this container was set up on.

Now we want to do something with the storage within the go application. To do this, we will need to use a Redis client: go-redis is a perfect one for our needs. We will also need to set up the module system to install these packages (within the container).

Run the following:

$ docker-compose up -d
$ docker-compose exec app go mod init <name-of-your-module>
$ docker-compose exec app go get github.com/go-redis/redis

This will set up your go.mod module file and install go-redis.

As for what to do with the actual implementation: how about we just increment a counter, and display this on the original endpoint?

main.go

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
    "log"
    "net/http"
    "os"
)

func main() {
    rdb := redis.NewClient(&redis.Options{
        Addr: os.Getenv("REDIS_HOST") + ":" + os.Getenv("REDIS_PORT"),
    })

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        val, err := rdb.Incr(context.Background(), "counter").Result()

        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            w.Write([]byte(err.Error()))
            return
        }

        w.Write([]byte(fmt.Sprintf("Hello, world! You are visitor #%d", val)))
    })

    port := os.Getenv("HTTP_PORT")

    log.Printf("listening on port %s\n", port)

    panic(http.ListenAndServe(":"+port, nil))
}

Run the app and hit the endpoint. You should see the counter increment in the response!

$ curl http://localhost:8000
Hello, world! You are visitor #1
$ curl http://localhost:8000
Hello, world! You are visitor #2

If a developer is using their host Redis instance, they can configure REDIS_HOST and REDIS_PORT to point to said instance instead of using the Docker instance as well, meaning that we have the full flexibility that was originally desired.

Host file permissions

This is all looking great now, but you may have noticed an issue when editing mounted files from the host machine. Docker creates files with the user assigned within the container, meaning that you are likely to get permissions errors if you try and edit files that were created in the container first (e.g. try editing go.mod in your host’s file editor/IDE).

One way of fixing this is to chown the file or directory whenever you encounter this permissions issue:

$ sudo chown $USER <filename>

This is only a temporary fix though. A better solution is to build the container with a user that has the same username and ID as your host machine. So instead of using an image in the docker-compose file, we will create a Dockerfile with the build steps:

Dockerfile

FROM golang:1.14-alpine

## The below is a mix of:
## https://vsupalov.com/docker-shared-permissions/
## https://stackoverflow.com/a/55757473

ARG USER_ID
ARG USER

RUN adduser --disabled-password --gecos "" --uid $USER_ID $USER

USER $USER

This creates a non-root user with the build arguments USER_ID and USER, and sets that user to be the one for running future commands. We then change the docker-compose file to use build instead of image:

docker-compose.yaml

version: '3'

services:
  app:
    working_dir: /app
    stdin_open: true
    build: ./
    volumes:
      - ./:/app
    env_file:
      - .env
    ports:
      - ${HTTP_PORT}:${HTTP_PORT}

  redis:
    command: redis-server --port ${REDIS_PORT}
    image: redis:6.0-alpine
    env_file:
      - .env
    ports:
      - ${REDIS_PORT}:${REDIS_PORT}

Now we build the container with a command that passes the current $USER and user ID in:

$ docker-compose build --build-arg USER=$USER --build-arg USER_ID=$(id -u)

And when starting the application, you should no longer encounter these permissions issues. 👌

Closing thoughts

Over the course of this article, we’ve learned how to set up a containerized app that works for local development, allowing future developers to get productive quickly. Hopefully this will enable you to make the local development experience for other members of your team more enjoyable and more consistent!

For a full reference to the final code – which has make targets for running the commands more easily, as well as some other tweaks – check out my Github repository.

Helpful resources

Back to Articles