The goal of this exercise is to learn to deploy a multi-container web application with Docker Compose. You will create a portable Compose file that can be used to deploy the same containers on both your local machine and your cloud server.
- Legend
- 💎 Meet the new boss, same as the old boss
- 💎 Make sure you have everything you need
- 💎 Spot the differences
- ❗ Create the Compose file
- ❗ Define the database service
- ❗ Define the application service
- ❗ Define the reverse proxy service
- ❗ Run the Compose project
- ❗ Deploy it on your cloud server
- 🏁 What have I done?
Parts of this guide are annotated with the following icons:
- ❗ A task you MUST perform to complete the exercise.
- ❓ An optional step that you may perform to make sure that everything is working correctly.
⚠️ Critically important information about the exercise.- 💎 Tips on the exercise, reminders about previous exercises, or explanations about how this exercise differs from the previous one.
- 👾 More advanced tips on how to save some time. Challenges.
- 📚 Additional information about the exercise or the commands and tools used.
- 🏁 The end of the exercise.
- 🏛️ The architecture of what you deployed during the exercise.
- 💥 Troubleshooting tips: how to fix common problems you might encounter.
This exercise is basically the same as the previous Docker Compose exercise, but it illustrates how to deploy a slightly more complex application than the PHP todolist with Docker Compose, namely the WOPR application you deployed during another exercise.
Since the basic structure of the exercise is the same, this exercise will only list what is different.
You need a fork of the WOPR repo on GitHub, which you should already have if you performed the WOPR deployment exercise.
Make sure you have a clone of this fork somewhere on your local machine, and also on your cloud server. Likewise, you should already have them somewhere.
The architecture of the WOPR application is basically the same as the PHP todolist's. There are basically 3 components:
- The reverse proxy.
- The WOPR application.
- The database.
The main differences are:
- Where the PHP todolist only used PHP, WOPR is an application using two technology stacks:
- The database is Redis instead of MySQL.
You may think that since there are 2 parts to the application, a Ruby backend and a JavaScript frontend, you need to run 2 isolated containers for them. However, the JavaScript frontend, once compiled, is composed of purely static files. The Ruby backend knows how to serve these static files to the client's browser, where the JavaScript is executed. There is no need for an extra container for the frontend's static files, since there is nothing to execute on the server.
So, our Compose file for the WOPR application will look something like this:
name: wopr
services:
rp:
image: # ...
depends_on: # ...
ports: # ...
restart: # ...
volumes: # ...
app:
image: # ...
build: # ...
depends_on: # ...
environment: # ...
restart: # ...
db:
image: # ...
restart: # ...
Let's create this Compose file.
Compare to the previous exercise, you of course want to use a Docker image to run Redis instead of MySQL, since that is the database required by the WOPR application.
The database service is simpler to define this time, because Redis is a key-value NoSQL database which is:
- Schemaless: you do not need to create the database structure in advance before using it.
- Without authentication by default. You can set up authentication, of course, but the default configuration is not to have it.
This means that the official redis
Docker image requires
basically no configuration, so you can omit the environment
key compare to the
previous exercise.
Move into the WOPR repository on your local machine and add a compose.yml
file with the following contents:
name: wopr
services:
db:
image: # ...
restart: # ...
Fill in the blanks!
👾 Use an Alpine-based image for a smaller footprint.
This is that part that is more involved than in the previous exercise. The WOPR application is more complex than the PHP todolist. As previously mentioned, it has two technology stacks: Ruby and Node.js.
However, Docker images are intended to be lightweight and as simple as possible
by design. You will find an official ruby
Docker image
and an official node
Docker image, but there is no
"official Ruby & Node.js image".
You could make such an image with both Ruby and Node.js yourself (and someone may already have done it and published it on Docker Hub), but that's not really the Docker philosophy.
Enter Docker multi-stage builds.
A Dockerfile may actually contain more than one FROM
command and use
multiple base images:
- Each
FROM
instruction can use a different base, and each of them begins a new stage of the build. - You can selectively copy artifacts from one stage to another, leaving behind everything you don't want in the final image.
This is great! Not only will it allow us to use both Ruby and Node.js in the process of building our final Docker image, but it will also allow us to get rid of what we do not need in the final image (in this case: the Node.js dependencies required to build the frontend).
Add a Dockerfile
to your local WOPR repository:
# First stage: build the frontend
FROM ... AS build
...
# Final stage: build the application
FROM ...
...
Note that the first staged is named "build" by adding AS build
after the
first FROM
command. This will allow you to reference it in the final build
stage later.
Now fill in the blanks.
First, complete the FROM
command of the first stage to use
the official node
Docker image.
👾 Use an Alpine-based image for a smaller footprint.
If you read WOPR's documentation, there are two commands related to
building the frontend: npm ci
, which installs the required Node.js
dependencies; and npm run build
, which builds the frontend's static files.
That's what you need to put in a RUN
command in the first
stage.
📚 When you have multiple commands to run like this, you can either use multiple
RUN
commands in the Dockerfile:RUN npm ci RUN npm run buildOr you can use one long
RUN
command with the shell's&&
operator:RUN npm ci && npm run build
The second one is considered better because it only creates one additional layer in the Docker image, making a slightly more lightweight image.
Of course, to run these commands, the image will need the WOPR application's
files, so you'll need a COPY
command to copy the files
first.
It's also good practice to define a working directory with the WORKDIR
command so you know where the files are during the build.
You can use any directory, for example /app
. This WORKDIR
command should
come before both the COPY
and RUN
commands, so that the latter are run in
the context of the specified working directory.
Complete the FROM
command of the final build stage to use the official ruby
Docker image. Use an Alpine-based image for a smaller
footprint.
⚠️ Use Ruby 3.2.x and not the latest Ruby 3.3.x. There is currently a bug with Ruby 3.3.x on some processor architectures which will cause a segmentation fault when you try to run your image.
Define a WORKDIR
like in the previous build.
Like in the first Docker exercise, you want to create a dedicated group and
user to avoid
root
-related security issues. You can do so with the following RUN
command:
RUN addgroup -S wopr && adduser -S wopr -G wopr && \
chown -R wopr:wopr /app
💎 The above
RUN
command assumes that you have chosen/app
as yourWORKDIR
. Change it if necessary.
To set up the backend, you basically have one command to run as per WOPR's
documentation: bundle install
. Put that in a RUN
command in the
final stage.
Of course, this command needs the application's files, so add the appropriate
COPY
command before the RUN
command.
💎 Read the
COPY
command's documentation and be sure to use the--chown
option. You want the copied files to be owned by the dedicatedwopr
user and group you just created.
Some of the WOPR application's dependencies need to be compiled, so you need to
install compilation tools like you did during the WOPR
exercise. Add one
of the following commands at the beginning of the final stage (it needs to be
before running bundle install
):
RUN apk add --no-cache build-base
There's one last thing you need to do. When you performed the original WOPR
exercise, the npm run build
command created the public
directory containing
the frontend's compiled files. But now that you have a multi-stage Dockerfile,
the two build stages are isolated by default: they have different file systems.
So the final stage is missing the result of the first stage.
You need to manually copy over the result from the first build stage into the final stage:
COPY --chown=wopr:wopr --from=build /app/public/ ./public/
💎 The above
COPY
command assumes that you chose/app
as yourWORKDIR
. Change it if necessary.📚 The
chown
option of theCOPY
command, named after Unix's equivalentchown
command, sets the ownership of the copied files in the target stage. In this case, you want all the files to be owned by the dedicatedwopr
user and group you created earlier.
One last thing: your image must actually run the WOPR application. Reading
WOPR's documentation, you can see that the command to do so is
bundle exec ruby app.rb
.
Add the appropriate CMD
command at the end of the final
build stage to run the application.
Do not forget to add a .dockerignore
file to the WOPR repository. The main
artifacts you want to ignore in this case are the node_modules
and the
public
directories.
If you have written the Dockerfile correctly, you should be able to build it without errors:
$> docker build -t wopr/app .
You can now add the WOPR application service to the Compose file:
...
name: wopr
services:
app:
image: # ...
build: # ...
depends_on: # ...
environment: # ...
restart: # ...
# ...
In principle, this is the same as the previous exercise's application service.
The only real difference is that instead of setting the $TODOLIST_DB_HOST
and
$TODOLIST_DB_PASS
(and optionally other) variables, you need to set the
$WOPR_REDIS_URL
variable as per WOPR's documented
configuration.
The value needs to be a Redis connection URL in the format
redis://<host>:<port>
. You can replace <host>
with db
, like in the
previous exercise, and replace <port>
by Redis's default 6379 port (or remove
:<port>
altogether, which will use the default).
The reverse proxy service is basically the same as in the previous exercise:
services:
rp:
image: # ...
ports: # ...
depends_on: # ...
restart: #...
volumes: #...
# ...
The only difference is that the nginx site configuration is really simple since there is no FastCGI madness. All that is needed is to proxy requests to the application service:
server {
listen 0.0.0.0:80;
server_name _;
location / {
proxy_pass http://app:4567;
}
}
Choose a different port to publish the reverse proxy service, say 14000
.
You now have the whole Compose architecture for this exercise: the database, the application, and the reverse proxy.
All that's left to do is run it:
$> docker compose up --build --detach rp
You should see your services running:
$> docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
wopr-app-1 wopr/app "bundle exec ruby ap…" app 8 seconds ago Up 7 seconds
wopr-db-1 redis:7.2.4-alpine "docker-entrypoint.s…" db 10 hours ago Up 6 minutes 6379/tcp
wopr-rp-1 nginx:1.25.3-alpine "/docker-entrypoint.…" rp 8 seconds ago Up 7 seconds 0.0.0.0:14000->80/tcp
💥 If not everything is running, you might want to stop everything with
docker compose down
and run the project in the foreground withdocker compose up --build rp
. It will be easier to see errors that way. Otherwise you can usedocker compose logs
.
Assuming you chose port 14000
and that you have configured everything
correctly, you should be able to use the WOPR application at
http://localhost:14000!
Yay! 🎉
The final deployment step of this exercise is basically the same as in the previous exercise, except that:
- You must of course use the WOPR repositories on your local machine, GitHub and your cloud server instead of the PHP todolist repositories.
- You have already installed Docker on your cloud server, so no need to do it again.
- An
.env
file is not required for WOPR since there is no sensitive value in the configuration (although you could use one to make the project more configurable). - Choose another
server_name
, e.g.wopr-docker.john-doe.archidep.ch
, and another nginx site configuration file name, e.g.wopr-docker
.
If you follow the previous exercise's instructions and take these changes into account, you should be able to easily replicate your Compose WOPR deployment on your cloud server and access it at http://wopr-docker.john-doe.archidep.ch!
You have learned how to build and deploy a more complex multi-stack web application with Docker and Docker Compose. As you can see, a project can use any number of technologies; between multi-stage Dockerfile builds and multi-container Compose projects, Docker and Docker Compose have you covered.
This is a simplified architecture of the main running processes and communication flow at the end of this exercise (only including the processes relevant to this exercise and not those from previous exercises):