Publishing ports the right way
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 exampledocker-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, but0.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 thedocker-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 theCOMPOSE_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
transformed80: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:
Comments powered by Talkyard.