If you are developing applications in Docker containers, publishing ports is most likely something you have to do to interact with your application.

There are some subtleties to keep in mind when it comes to publishing ports:

  • Security : It is always a good idea to only listen on localhost if possible.
  • Flexibility: You might need to change on what port your application runs.
  • Access to downstream services: There will be times where you want to use a tool or a script that runs on your Docker Host to access your database management system or another service running in a container.

In this article we will use nothing but the built-in features of Docker Compose to address these concerns.

The baseline

The following docker-compose.yml is going to be our example configuration that we will iterate on:

version: "3.7"

services:
  app:
    image: jfahrer/my-rails-app:development
    build: .
    ports:
      - 3000:3000
    volumes:
      - ./:/usr/src/app:cached
    environment:
      - POSTGRES_HOST=pg
    command: ["rails", "server", "-b", "0.0.0.0"]

  pg:
    image: postgres:10.6-alpine
    volumes:
      - pg-data:/var/lib/postgresql/data

volumes:
  pg-data:

We have two services, a Rails application providing a web service and Postgres as our database management system.

The Rails server is published on port 3000 while Postgres is not published at all at the moment.

Let’s make this config more flexible and secure.

Listen on localhost only

Our application currently publishes port 3000 on /all/ interfaces on the Docker Host. This is less ideal from a security perspective - everyone on the same network could access the service.

Let’s change that by adding 127.0.0.1 to the port mapping:

    ports:
      - 127.0.0.1:3000:3000

This will make sure that Docker will only bind port 3000 on our loopback interface with the IP address 127.0.0.1 (aka localhost).

To make all of this a bit more confusing: While we want to bind localhost/127.0.0.1 on our Docker Host, our container has to bind the port on an interface we can actually talk to from our machine. In the example docker-compose.yml, we use-b 0.0.0.0 to tell our application to bind itself to all available interfaces. The -b flag is specific to Rails, but 0.0.0.0 generally means /all IPs/.

Keeping the port flexible

What about port conflicts? I often work on more than one project at the same time. Sometimes the configured ports in those projects overlap.

In a local dev environment that is usually no big issue - one would just set the desired port via a command line option. With Docker Compose we usually just run docker-compose up and rely on Compose to start everything with the correct arguments.

We can work around that by utilizing the environment substitution functionality of Docker Compose:

    ports:
      - 127.0.0.1:${MY_APP_PORT:-3000}:3000

${MY_APP_PORT:-3000} means: Use the value of the environment variable MY_APP_PORT if possible. If the environment variable is not set or empty, use 3000 instead.

We can not specify the port on the command line by running:

MY_APP_PORT=3001 docker-compose up

If you want to set the port on a more permanent basis, add it to your .env file:

MY_APP_PORT=3001

Docker Compose will read the .env file automatically. That means you can simply run

docker-compose up

or any other docker-compose command .

Publishing additional services

So, what about Postgres, our database management service? We could add a port mapping and publish port 5432 - the port that Postgres is listening on:

  pg:
    image: postgres:10.6-alpine
    volumes:
      - pg-data:/var/lib/postgresql/data
    ports:
      - 127.0.0.1:5432:5432

Again, this might lead to collisions with other projects or a local instance of Postgres. Instead, let’s publish the service on a random port:

    ports:
      - 127.0.0.1::5432

We simply omit the value for the port on the Docker Host and Docker will assign a random port.

And of course we could combine this with the environment replacement functionality:

    ports:
      - 127.0.0.1:${MY_APP_POSTGRES_PORT}:5432

If MY_APP_POSTGRES_PORT is empty, the port mapping will expand to 127.0.0.1::5432.

We could specify a default value here, but for downstream services I usually don’t do this. The chance of a collision are pretty high - a lot the applications I work on use Postgres and Redis. In most cases I don’t need to access these services directly on a regular basis. If I have to, a quick docker-compose ps will tell me the port to connect to.

But using an environment variable here allows me to set a static port for the cases where I want to publish the service on a known port. All I have to do is add it to my .env file:

MY_APP_POSTGRES_PORT=5432

Keeping the mapping itself flexible

If you feel the need to let developers decide how to publish a certain service, just make the whole port mapping an environment variable:

    ports:
      - ${MY_APP_PORT_MAPPING:-127.0.0.1:3000:3000}

If MY_APP_PORT_MAPPING is not defined, the environment variable will be expanded to 127.0.0.1:3000:3000. If I wanted to change the mapping, I can simply set the MY_APP_PORT_MAPPING environment variable to whatever I want:

MY_APP_PORT_MAPPING=3001:3000

What if the port has to be identical on both ends?

In certain scenarios you might have the requirement that the local port and the port in the container need to match. For example to make redirects or links in templates work correctly.

Let’s assume we would need to make sure that we start our application with

rails server -p 3001

to make the app listen on port 3001 instead of 3000.

Locally, all we have to do is run the command as is. In Docker land we have to change the command and the port mapping dynamically. Luckily, environment substitution also work for the command directive!

services:
  app:
    image: jfahrer/my-rails-app:development
    build: .
    ports:
      - 127.0.0.1:${MY_APP_PORT:-3000}:${MY_APP_PORT:-3000}
    volumes:
      - ./:/usr/src/app:cached
    environment:
      - POSTGRES_HOST=pg
    command: ["rails", "server", "-b", "0.0.0.0", "-p", "${MY_APP_PORT:-3000}"]

And there we go! We can now set MY_APP_PORT to whatever port we want and our port mappings and the command to start our Rails server will change dynamically.

How to iterate on your config

Use the docker-compose config command to make sure that your config works as expected!

Why not use a docker-compose.override.yml?

If Docker Compose finds a docker-compose.override.yml next to the docker-compose.yml, it will automatically merge the two files! This is super handy to extend or override settings specified in the global compose file. You can achieve the same by using the -f flag (or the COMPOSE_FILE environment variable) to specify multiple compose files.

Merging multiple compose files to change the configuration works great for directives that are not arrays - for example the image name.

For directives that expect an array, like ports, the resulting configuration will contain the elements of both arrays.

Here is an example docker-compose.yml:

version: "3.7"

services:
  nginx:
    image: nginx:latest
    ports:
      - 80:80

Here is a matching docker-compose.override.yml

version: "3.7"

services:
  nginx:
    ports:
      - 127.0.0.1:8080:80

You might expect that the service will only be published via 127.0.0.1:8080:80 , but if we look at the output of docker-compose config we will see that this is not the case:

version: '3.7'
services:
  nginx:
    image: nginx:latest
    ports:
    - published: 80
      target: 80
    - 127.0.0.1:8080:80/tcp

As you can tell, we have two definitions in our ports directive.

docker-compose config transformed 80:80 to use the long syntax - this is just a side effect of the parser and does not change the behavior in any way. Just in case you were wondering about that.

I hence prefer using environment substitution to make the configuration more dynamic and flexible and give developers the choice to change settings. I find this specifically useful in combination with a .env file.

The final docker-compose.yml

version: "3.7"

services:
  app:
    image: jfahrer/my-rails-app:development
    build: .
    ports:
      - 127.0.0.1:${MY_APP_PORT:-3000}:${MY_APP_PORT:-3000}
    volumes:
      - ./:/usr/src/app:cached
    environment:
      - POSTGRES_HOST=pg
    command: ["rails", "server", "-b", "0.0.0.0", "-p", "${MY_APP_PORT:-3000}"]

  pg:
    image: postgres:10.6-alpine
    volumes:
      - pg-data:/var/lib/postgresql/data
    ports:
      - 127.0.0.1:${MY_APP_POSTGRES_PORT}:5432

volumes:
  pg-data: