Docker and Docker Compose
Learning objectives
- You know that there can be separate development and production configurations for Docker.
- You know how to specify configuration files when running docker compose.
Multiple Dockerfiles
When creating separate development and production configurations, it is possible to create development- and production-specific Dockerfiles. As an example, The Dockerfile
for our items-api
has looked as follows.
FROM denoland/deno:alpine-1.42.2
EXPOSE 7777
WORKDIR /app
COPY deps.js .
RUN deno cache deps.js
COPY . .
CMD [ "run", "--unstable", "--watch", "--allow-net", "--allow-read", "--allow-env", "app.js" ]
The flag --watch
starts the server in a mode where the application watches changes to the files. If a file changes, the server is restarted. In production, such a flag would be unnecessary. We could also consider the need for other flags -- for example, --allow-read
might not be needed.
To define a Dockerfile with production configuration, we create a separate Dockerfile that we then use. Let's create a file called Dockerfile.prod
, which is otherwise similar to the above configuration, but does not have the --watch
and the --allow-read
flag.
FROM denoland/deno:alpine-1.42.2
EXPOSE 7777
WORKDIR /app
COPY deps.js .
RUN deno cache deps.js
COPY . .
CMD [ "run", "--unstable", "--allow-net", "--allow-env", "app.js" ]
In this case, the folder items-api
would hold two Dockerfiles. The file Dockerfile
would be used for development, and the file Dockerfile.prod
would be used for production.
Multiple Docker Compose configurations
To choose which Dockerfile to use when starting up the application, we would have two separate Docker Compose configuration files. One that would be used for development purposes and one that would be used in production. When we consider our items-api
application, the parts related to the above configuration have presently looked as follows in our docker-compose.yml
file.
# ..
items-api:
build: items-api
image: items-api
restart: "no"
volumes:
- ./items-api/:/app
- ./app-cache/:/app-cache
expose:
- 7777
depends_on:
- database
- flyway
- redis
env_file:
- project.env
deploy:
replicas: 2
# ...
That is, the service called items-api
is built from the folder items-api
and the image is called items-api
. If the service fails, it is not restarted, and contents from the present folder are mapped to the application.
In production, we would want to specify a separate Dockerfile while still stating that the project is built from the folder items-api
, and we would likely want to call the image something else, such as items-api-prod
. We likely would not want to map the contents from the present folder to the application but would want the files to be within the image. In addition, we would wish that the image is restarted a few times on failure, and would likely wish to also keep the application cache within the image. In addition, we might wish to have a separate environment file to allow variables from the production environment.
When creating a new production-specific Docker Compose file -- let's call it docker-compose.prod.yml
-- these changes would look as follows (with the exception that the environment file is still the same).
# ..
items-api:
build:
context: items-api
dockerfile: Dockerfile.prod
image: items-api-prod
restart: "on-failure"
expose:
- 7777
depends_on:
- database
- flyway
- redis
env_file:
- project.env
deploy:
replicas: 2
restart_policy:
condition: on-failure
delay: "5s"
max_attempts: 5
window: "30s"
# ...
In a production environment, the application would also be started using the docker compose up
command, but with explicitly passing the name of the Docker Compose file. In addition, the -d
flag would be used to start the as a daemon. Together, the command would be as follows.
docker compose -f docker-compose.prod.yml up -d
The above command launches the application, creating it as a daemon. To stop the application, we would use the command docker compose down
.
Volumes and bind mounts
Let's take a peek at our configuration for the PostgreSQL database. Presently, the configuration is as follows.
# ...
database:
container_name: database-server
image: postgres:14.1
restart: "no"
env_file:
- project.env
# ...
When we start the application, the database is created, and the data is stored within the Docker image. When the command docker compose down
is run, the data in the database disappears.
Similar to the items-api
, we would wish to have a separate production configuration. For our database, ideally, the data would not vanish when we run the docker compose down
command. In addition, it would be preferable if the database would be accessible in a format where it could be backed up at relative ease.
A straightforward solution would be to map the data in the PostgreSQL image to the local file system, using a persistent volume. In addition, we would likely wish that the database is started on errors and in other situations -- in this case, the restart policy "unless-stopped" is useful.
With the below configuration, the database data would be stored locally in the folder production-database-data
and the database would be restarted when needed.
# ...
database:
container_name: database-server
image: postgres:14.1
restart: unless-stopped
volumes:
- ./production-database-data:/var/lib/postgresql/data
env_file:
- project.env
# ...
Now, even if we would run the docker compose down
command, the data would still persist.
For additional information, read the Docker documentation on Volumes.
Backing up the database
For backing up the database, we would -- for example -- set up a cronjob that would periodically access the database and run the pg_dumpall
command to export the database contents to a file. We will look into database replication in later parts.
Docker Compose profiles
Newer Docker Compose files feature also profiles that can be used to, for example, adjust which services are launched. As an example, in our applications, we might wish to run flyway migrations only in certain cases and similarly at times might benefit from a database admin user interface.
The following docker compose would specify that the service flyway
is started when the profile migrate
is active. Similarly, the service pgadmin
would be started when the profile pgadmin
is active.
version: "3.9"
services:
# ...
flyway:
image: flyway/flyway:9.11.0-alpine
depends_on:
- database
volumes:
- ./flyway/sql/:/flyway/sql
command: -connectRetries=60 -baselineOnMigrate=true migrate
env_file:
- project.env
profiles:
- migrate
pgadmin:
image: dpage/pgadmin4:6.20
env_file:
- project.env
depends_on:
- database
profiles:
- pgadmin
# ...
Specifying a profile is done with the --profile
flag, which is followed by the used profile. As an example, if we would run the project with the migrate
profile, we would use the command docker compose --profile migrate up
.
docker compose --profile migrate up
On the other hand, if we would wish to use both the migrate
profile and the pgadmin
profile, we could use the following command.
docker compose --profile migrate --profile pgadmin up
On the other hand, if we would simply use the normal docker compose up
command, neither the migrate
nor the pgadmin
profile would be active, and neither of the flyway
and pgadmin
services would be started.
For additional information on profiles, see Docker Compose documentation on Using service profiles.