Dockerfiles#

Docker isn’t only useful for running other people’s software (as we’ve been doing so far). It’s also a great way to build and package our own software.

Container image composition

I’ve used Docker both ways. As a DevOps/platform engineer I’m usually using other’s images, but as a backend developer I was usually building images for our own servers.

Docker images are built from Dockerfiles. A Dockerfile is just a text file that contains all the commands needed to assemble an image. It’s essentially the “Infrastructure as Code” (IaC) for an image. It runs commands from top to bottom, kind of like a shell script.

Instead of manually installing dependencies on servers and making updates manually, we can check a Dockerfile into source control and build it automatically. Mhmmmm, automation

Create a file called Dockerfile in your working directory. If you’re using VS Code, I’d recommend installing the Docker extension. It will give you some nice syntax highlighting. Inside the Dockerfile add these lines of text:

# This is a comment

# Use a lightweight debian os
# as the base image
FROM debian:stable-slim

# execute the 'echo "hello world"'
# command when the container runs
CMD ["echo", "hello world"]

Build a new image from the Dockerfile and call it helloworld:

docker build . -t helloworld:latest

The -t helloworld:latest flag tags the image with the name “helloworld” and the “latest” tag. Names are used to organize your images, and tags are used to keep track of different versions.

Run your image in a new container:

docker run helloworld

If all went well, you’ll see “hello world” printed to the console!

Run docker ps. You’ll notice that your container is not running anymore! All it did was print and exit. Just like regular programs, docker containers can execute simple commands that exit quickly, or they can execute servers that run until killed. It just depends on the command you give it. See the stopped container with docker ps -a. Delete the Dockerfile, we don’t need it anymore.

Dockerizing the Server#

Now that you know how to run your server manually, let’s run it in Docker! The steps are simple:

  1. Build the Server
  2. Create a Dockerfile
  3. Build an image using the Dockerfile (which will copy in the built server)
  4. Run the image in a container

  1. Create a Dockerfile in the root of your server’s repo. Let’s start with a simple lightweight Debian Linux OS:
FROM debian:stable-slim
  1. Add a COPY command on the next line of your Dockerfile. In the case of a simple compiled Go server, all we need is the compiled program itself!
# COPY source destination
COPY goserver /bin/goserver

Replace “goserver” with the name of your server executable if it’s different.

The ADD command would also work here, but COPY is fine because we don’t need the extra functionality that ADD offers.

  1. Add a CMD command as the last line in the Dockerfile. This automatically starts the server process in the container when we run it.
CMD ["/bin/goserver"]
  1. Build your Dockerfile into an image.
docker build . -t goserver:latest
  1. Start a new container from the image. Be sure to forward the ports to your host machine.
docker run -p 8010:8010 goserver

If you get an exec format error, it’s probably because you built the go server for your local architecture, but you’re trying to run it on a Linux OS! To fix it, rebuild the binary (and then the Dockerfile) with these flags:

GOOS=linux GOARCH=amd64 go build

  1. You should be able to access your server from the browser just like before, but this time it’s running inside of Docker!

Creating an Environment#

You may be thinking, “What’s the point of dockerizing this simple service”? Well, at the moment, there are only a couple of benefits:

  • Anyone with Docker can run your image, regardless of their OS
  • You can easily deploy containers of your image on any cloud service that uses images (most of them) or on an orchestration server like Kubernetes.
  • If your server were written in a language like Python or JavaScript, you could bundle the interpreter and dependencies inside the image so that you don’t need to reconfigure them on the server.

That said, because our app is so simple, there’s just not much environment required, and one of the best things about Docker is that it allows you to ship an entire environment.

So… let’s make it more interesting!

We’re going to make the port that our server binds to configurable: it will be set by an environment variable.

  1. Find the line that sets port to a hard-coded value of 8010 and update it so that it reads an environment variable called PORT. You can use os.Getenv:
port := os.Getenv("PORT")

Make sure that the os package is imported:

import (
	"fmt"
	"log"
	"net/http"
	"os"
	"time"
)
  1. Change the port to 8999 by setting an environment variable in your shell:
export PORT="8999"
  1. Rebuild and run your Go program and make sure it serves on port 8999. Remember to replace goserver with the name of your binary.
go build
./goserver
  1. Add an ENV command to your Dockerfile to set the port within the image. You’ll need to do it before the CMD command so that the environment variable is set before the server starts.
ENV PORT=8991
  1. Rebuild your Docker image:
docker build . -t goserver:latest
  1. Rerun your Docker container, be sure to expose the correct port:
docker run -p 8991:8991 goserver

example for js project#

Start by reading the application code to understand its configuration and signal handling.

The index.js file reveals two important details:

  1. The app requires PORT, LOG_LEVEL, and ENVIRONMENT environment variables and exits with an error if any of them is missing.
  2. It registers a graceful shutdown handler specifically for SIGINT (not the Docker’s default SIGTERM).
FROM node:24-slim
WORKDIR /app

COPY index.js .

ENV PORT=8080
ENV LOG_LEVEL=debug
ENV ENVIRONMENT=development

RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser

STOPSIGNAL SIGINT

CMD ["node", "index.js"]

Here’s what each section does:

  • ENV’s set default values for the configuration variables. These become baked into the image and are used unless overridden at runtime with docker run -e.
  • USER switches to a non-root user. The adduser --system creates a system user without a home directory or login shell - appropriate for service accounts.
  • STOPSIGNAL tells Docker to send SIGINT (instead of the default SIGTERM) when docker stop is called. This matches the signal the application actually handles for graceful shutdown.