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
- Setting up the initial container
- A local environment
- Storage
- Closing thoughts
- Helpful resources
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
- Heroku - Local Development with Docker Compose (2019)
- Mark Smith - Parse a .env (dotenv) file directly using BASH (2018)
- Vladislav Supalov - Avoiding Permission Issues With Docker-Created Files (2019)
- @rexypoo - Response to “How do I add a user when I’m using Alpine as a base image?” (2019)
- Leon Carlo Valencia - Response to “Interactive shell using Docker Compose” (2016)