Docker empowers developers to encapsulate their applications and dependencies into self-contained images, ensuring seamless deployment across diverse environments, and thus freeing you from infrastructure concerns.
The process involves writing a Dockerfile, building a Docker image, and then
running it as a container. This guide specifically focuses on preparing Docker
images for Go applications in development and production contexts.
By the end, you'll possess the knowledge to run Go applications confidently within containers either locally or on your chosen deployment platform.
Let's get started!
Prerequisites
- Prior Go development experience.
- Familiarity with the Linux command-line.
- Access to a Linux machine with Docker Engine installed.
Step 1 — Setting up the demo project
To demonstrate Go application development and deployment with Docker, I'll assume you already have a Go application ready. If not, you can clone the Blogging application prepared for this demonstration to your computer. It's a simple CRUD application that persists created posts to a PostgreSQL database.
Our goal is to containerize this Go application using Docker, making it easily deployable in various environments.
Execute the command below to clone the application from GitHub:
Then navigate into the project directory and install its dependencies:
Once the dependencies are installed, build the application with:
You will see a new go-blog binary within your current working directory's
bin folder. Before you execute the binary, let's set up a PostgreSQL database
through the
official PostgreSQL Docker image. Without
an active PostgreSQL instance, the blogging application will fail to work.
Open a separate terminal and run the command below to launch a PostgreSQL container based on the Bookworm variant of the image:
This command sets up a PostgreSQL container named go-blog-db, removes it upon
stopping (--rm), and maps port 5432 to your host machine. It also sets the
default database name to go-blog and the password for the default postgres
user to admin.
You should see the following log entry confirming that the database is ready to accept connections:
With the database now running, return to your project directory in a different
terminal and rename the .env.sample file to .env:
Open the .env file in your text editor and populate it with the contents
below. The POSTGRES_DB and POSTGRES_PASSWORD variables should correspond to
the values used when starting the PostgreSQL container.
Once you're done, return to the terminal to run the database migrations. Our
demo project has just one migration file, which creates the posts table if it
doesn't already exist:
To apply the migration, you must install golang-migrate first, then execute:
Ensure to replace the placeholders above with the appropriate values. In my case, it will be:
You will see the following message if the migration succeeds:
Finally, launch the application with:
The following message confirms that the application is listening for connections on port 8000:
To confirm that everything works, visit http://localhost:8000 in your browser:
Create a new post to test out its functionality:
You should see the new post on the application home page:
Now that you've confirmed the functionality of your Go application, let's
proceed to the next step where you'll create a Dockerfile for the project.
Step 2 — Writing a Dockerfile for your Go app
Before you can build a Docker image for your Go application, you need to create a Dockerfile. This text file guides the Docker engine through the process of assembling the image that will encapsulate your application and its runtime environment.
The format of the Dockerfile is shown below:
Any line starting with # denotes a comment (except
parser directives).
Other lines must include a specific command, followed by its corresponding
arguments. Although command names are not case-sensitive, they are commonly
capitalized to differentiate them from arguments.
Here's the Dockerfile for our blog application in full:
This Dockerfile packages a Go application and its dependencies into a Docker
image. Here's a line-by-line explanation of its contents:
This line sets the foundation for your Docker image. It specifies that the image will be built upon the official Go image using Debian's Bookworm release as its base.
While you will commonly see Alpine Linux being used as the base for Docker images due to its small size, it's worth considering that its use can come with potential drawbacks.
Notably, Alpine's reliance on musl libc
instead of the prevalent glibc can create
compatibility hurdles for some software, potentially triggering unexpected
errors or crashes. This is especially likely to occur if your Go application
necessitates CGO_ENABLED=1.
Moreover, Alpine has a history of encountering DNS resolution issues with certain hostnames, which can impede network connectivity and disrupt the smooth operation of applications.
Therefore, it's generally recommended to select a more mainstream base image such as Debian or Ubuntu. These distributions provide superior compatibility and stability, potentially sparing you from time-consuming troubleshooting efforts in the future.
We'll address the optimization of the final image size through the implementation of multi-stage builds in a subsequent section of this tutorial.
The WORKDIR instruction sets the working directory inside the container to
/build. All subsequent commands will be executed within this directory unless
explicitly stated otherwise.
This copies the go.mod and go.sum files from your project directory into the
/build directory within the image.
This command downloads all the dependencies listed in the previously copied
go.mod and go.sum files, ensuring they are available for the subsequent
build step.
This copies all files and directories from your current local directory into the
/build directory within the image.
The next step is to compile your Go application by executing the go build
command. The resulting executable is named go-blog.
This line informs Docker that the containerized application will likely listen
on port 8000. You'll still need to use the -p flag to publish the container
port and map it to a host port.
Finally, the CMD instruction specifies the default command to be executed when
a container is started from this image. In this case, it executes the go-blog
executable.
With these instructions in place, you're ready to build the Docker image.
Step 3 — Building the Docker image and launching a container
With your Dockerfile ready, it's time to build the Docker image. However,
let's create a .dockerignore file in your project's root directory first. This
file instructs Docker to exclude the specified files and directories from the
build context to prevent unnecessary or sensitive files from accidentally being
included.
In our case, let's ignore the bin and .git directories, and any sensitive
configuration files:
One you've saved the file, build the Docker image by executing:
The -t flag assigns the go-blog name to the image. You can also add a
version tag, such as 0.1.0, using the command below:
Without a tag, Docker defaults to latest.
After the build completes, confirm that the new image is present in your local image library:
This image is quite large at 1.06GB, but you will see how to reduce it significantly in Step 6 of this tutorial.
For now though, let's launch a Docker container based on the go-blog image.
Before proceeding, be sure to stop any currently running instance of the Go
application with Ctrl-C, but keep your PostgreSQL container running as before.
Execute the following command to launch the container for your application:
This command creates a go-blog-app container from your go-blog image. The
--rm flag ensures the container is automatically removed when stopped, while
the --publish 8000:8000 part maps port 8000 inside the container to port 8000
on the host machine.
The --env-file flag provides a handy way to configure your Go application
within the container through an .env file without exposing application secrets
in the Docker image.
However, you will observe the following error:
This happens because your Go application is trying to establish a connection
with a PostgreSQL database assumed to be running on localhost within the
go-blog-app Docker container. Since there's no PostgreSQL instance active
inside that container, it predictably fails.
To facilitate communication between your Go application container and your
PostgreSQL container, you need to use the container name (go-blog-db in this
case) as the database host instead of localhost, then create a
dedicated Docker network,
and ensure both containers are placed on the network through the --network
option.
Enter the following command below to create the network:
With the go-blog network created, stop the existing PostgreSQL container
instance and restart it on the new network through the --network option:
Once its up and running once again, update your project's .env file as
follows:
The POSTGRES_HOST environment variable has been changed from localhost to
the name of your database container (go-blog-db). This change allows the
application container to find the PostgreSQL instance running in the separate
database container once you apply the --network option:
It should launch successfully now:
You can now interact with the application at http://localhost:8000 once again
and it should keep working the same way as before.
Step 4 — Configuring up a web server
Web servers such as Nginx or Caddy are often deployed in front of Go applications to handle tasks like load balancing, reverse proxying, serving static assets, SSL, and caching. This allows the application to focus on the main business logic, while the web server handles the ancillary tasks in a more robust and scalable manner.
In this section, we'll configure Caddy as a reverse proxy for our Go application. Ensure that both the Go application and the PostgreSQL containers are running, then open a new terminal and launch a container based on the official Caddy Docker image with:
Once the "serving initial configuration" message appears, visiting
http://localhost in your browser will display the default "Caddy works!" page:
Terminate the container with Ctrl-C, and then create a Caddyfile in your
project's root:
This configures Caddy to listen on http://localhost and forward all incoming
requests to the go-blog-app container, which is expected to be listening on
port 8000.
To witness the effect, relaunch the Caddy container with these additions for persistent storage and network integration:
This command now incorporates:
- Persistent volumes (
caddy-configandcaddy-data) to store Caddy's configuration and data, respectively. - A custom
Caddyfileconfiguration. - The
go-blog-networkfacilitating communication with your Go application container.
At this stage, accessing http://localhost should present your Go application,
served through Caddy.
Step 5 — Orchestrating multiple containers with Docker Compose
Managing multiple containers can be cumbersome, especially for applications comprising several microservices running independently. This is where Docker Compose steps in.
It offers a structured approach to managing multi-container applications by defining component services, networks, and volumes in a single YAML configuration file. Then you can launch or stop all the services defined in your configuration file with a single command.
Let's create a docker-compose.yml file in your project's root to orchestrate
your Go application and its dependencies:
This configuration defines three services:
app: Your Go application, dependent on a healthypostgresservice.caddy: A Caddy web server acting as a reverse proxy, serving on port 80 and using a customCaddyfile.postgres: A PostgreSQL database, configured with specific settings and a health check.
These are the three services we've been manually launching with docker run in
the previous sections. They share the go-blog-network and utilize volumes for
data persistence.
Placeholders are used for environment variables, which are populated from your
.env file when the containers start. Notably, your Go app's port is no longer
directly exposed, making the app accessible only via Caddy at
http://localhost.
Before starting the services with docker compose, stop and remove the existing
containers with:
Now, launch all three services using Compose:
This builds the app image and starts all three services in the foreground,
displaying their logs:
Now refresh the http://localhost web page:
The posts database table appears to be missing because we didn't run the
database migrations like we did earlier in Step 1.
However, instead of manually invoking the migrate command once again, let's
add a migrate service to the docker-compose.yml file that automatically
launches a container to execute the migrations:
The migrate service utilizes the
migrate/migrate Docker image to
automate database migrations. It waits for the PostgreSQL database to be
healthy, then applies any pending "up" migrations from your local
repository/migrations directory to the database, ensuring that its structure
stays aligned with your application's code.
Instead of depending on the postgres service, update the app service to
start when the migrate service exits successfully:
Return to your terminal and stop the existing services with:
Relaunch the services once again by executing:
You will observe that the migration ran successfully before the app service
was launched:
When you return to the application user interface, it will load successfully, and you can create blog posts as before.
There you have it! You can now manage all your services and their dependencies with a single command, streamlining your development and deployment workflow.
Let's now explore developing your application directly within Docker for an even better development experience!
Step 6 — Creating a Docker-driven Go development workflow
Now that you've containerized your application and orchestrated its services with Docker Compose, let's turn Docker into a productive development environment for Go applications by integrating live reloading, while reducing the image size for production builds.
Begin by modifying your Dockerfile to utilize
multi-stage builds:
In this improved Dockerfile, we've introduced a development stage which uses
the air CLI for a seamless code-edit-refresh
cycle, a builder stage to compile your Go application to a static binary, and
a production stage that only copies the compiled executable to the minimal
scratch image, resulting in a smaller and
more efficient production image.
In the development stage, you'll notice that we didn't copy the project's
contents into /app directory. Instead, we'll mount the local project directory
to the /app directory within the container to enable automatic reloads via
air. You only need to update your app service as follows:
Here, we've mounted the local project directory to /app within the container.
This allows you to make code changes directly on your host machine, and they'll
be reflected instantly inside the container.
The services.app.build.target value is also set to ${GO_ENV} so that when
you're building the image, the appropriate stage is used according to the value
supplied through the .env file.
Once you're done, return to your terminal and execute the command below to stop and remove your existing service instances:
Then run:
Assuming the GO_ENV variable in your .env file is set to development, the
corresponding stage will be triggered and you will see that the air CLI is
installed and launched successfully:
To confirm that live reload works, add a simple health check route to your
main.go file like this:
Once you save the file, you'll notice that the change is detected by air which
automatically triggers a new build and execution of the updated binary:
When its time to build a production image, you can specify the production
stage with the --target flag like this:
Once the image builds, inspect it with:
You'll notice that the image size is now just 17.8Mb as opposed to the 1.06GB observed in step 3. This reduction in image size has several benefits, including faster image pull, reduced storage requirements, and quicker deployment times.
If you're launching your services in production with docker compose, you only
need to change your GO_ENV value to production before running
docker compose up --build as before.
Final thoughts
You've successfully journeyed through the steps associated with crafting a Docker image for your Go application, and how to leverage it for both local development and production environments.
But remember that this is only the first step in your Docker adventure. There are many paths to further refine your development and deployment processes.
Feel free to dig deeper into topics like:
- Streamlining your Dockerfile.
- Enhancing security in your containerized applications.
- Effective logging strategies for Docker.
- Deploying your Dockerized applications seamlessly on cloud platforms like AWS.
Want to tinker with the demo? You'll find the final version of the code on GitHub.
Keep learning, keep building, and most of all, have fun coding!