33.
Deploying with Docker
Written by Tim Condon
Docker is a popular containerization technology that has made a huge impact in the way applications are deployed. Containers are a way of isolating your applications, allowing you to run multiple applications on the same server.
Using a container, instead of a full-fledged virtual machine, allows your containerized applications to share more of the host machine’s resources. In turn, this leaves more resources for your application to use rather than consuming them to support the virtual machine itself.
Docker can run almost anywhere, so it provides a good way to standardize how your application should run, from local testing to production.
Note: If you need a refresher on Docker terminology — concepts such as containers and images — check out our Docker tutorial at https://www.raywenderlich.com/9159-docker-on-macos-getting-started.
Docker Compose
This chapter will also show you how to use Docker Compose. Docker Compose is a way to specify a list of different containers that work together as a single unit. These containers share the same virtual network, making it simple for them cooperate with each other.
For example, with Docker Compose, you can spin up both your Vapor app and a PostgreSQL database instance with just one command. They can communicate with each other but are isolated from other instances running on the same host.
Setting up Vapor and PostgreSQL for Development
Begin by setting up a simple development configuration to test your app in a Linux environment. To facilitate debugging any problems that arise, this will be a much simpler configuration than you’ll use in production.
Note: This chapter’s sample project is identical to the project at the end of Chapter 21, “Validation”. You may use it or you may continue to use your existing project.
In the main directory for your project, create a file named develop.Dockerfile and add the following contents:
#1
FROM swift:5.3
#2
WORKDIR /app
#3
COPY . .
#4
RUN swift package clean
RUN swift build -c release --enable-test-discovery
RUN mkdir /app/bin
RUN mv `swift build -c release --show-bin-path` /app/bin
EXPOSE 8080
#5
ENTRYPOINT ./bin/release/Run serve --env local \
--hostname 0.0.0.0
A Dockerfile provides the “recipe” for creating a Docker container for your app. Here’s what this one does:
- Use version 5.3 of the “swift” image from the Docker Hub repository as the starting point.
- Tell Docker to use /app as its working directory.
- Copy your project to the Docker container.
- Build your project and move the executable to /app/bin within the container. Note the use of
--enable-test-discovery. Swift requires this to build your project even though you’re not running any tests. - Tell Docker how to start the Vapor app.
Next, also in your project’s main directory, create a file named docker-compose-develop.yml and add the following contents:
# 1
version: '3'
# 2
services:
# 3
til-app:
# 4
depends_on:
- postgres
# 5
build:
context: .
dockerfile: develop.Dockerfile
# 6
ports:
- "8080:8080"
environment:
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
# 7
postgres:
# 8
image: "postgres"
# 9
environment:
- POSTGRES_DB=vapor_database
- POSTGRES_USER=vapor_username
- POSTGRES_PASSWORD=vapor_password
# 10
start_dependencies:
image: dadarek/wait-for-dependencies
depends_on:
- postgres
command: postgres:5432
A Docker Compose file specifies the “recipe” for your entire app with all of its dependencies. Here’s what this one does:
- Specify the Docker Compose version.
- Define the services for this application.
- Define a service for the TIL application.
- Set a dependency on the
postgresservice so Docker Compose starts the PostgreSQL container first. - Build develop.Dockerfile in the current directory. This is the Dockerfile you created earlier.
- Make port 8080 accessible on the host system and inject the
DATABASE_HOSTenvironment variable. Docker Compose has an internal DNS resolver. This allows thetil-appcontainer to connect to thepostgrescontainer with the hostnamepostgres. Also set the port for the database. You can specify any other environment variable values your app needs here, such as GitHub OAuth credentials. - Define a service for the PostgreSQL database.
- Use the standard
postgresimage. - Set the necessary environment variables.
- Docker starts all containers at once and PostgreSQL takes several seconds to become ready to accept connections. If TILapp starts before PostgreSQL is ready, TILapp will crash. This service provides a way to ensure the database is running before starting your app.
To bring your app to life, enter the following commands in Terminal:
# 1
docker-compose -f docker-compose-develop.yml build
# 2
docker-compose -f docker-compose-develop.yml run --rm start_dependencies
# 3
docker-compose -f docker-compose-develop.yml up til-app
Here’s what this does:
- Build the different Docker images defined in docker-compose-develop.yml.
- Run the
start_dependenciesservice from docker-compose-develop.yml to ensure that PostgreSQL is running and ready. - Start your app.
If you receive an error stating the “vapor” database is not found, follow the clean up steps below and retry the commands above and the application should start successfully. This error might occur if you have previous Docker PostgreSQL images on your system.
In your browser, visit http://localhost:8080 to verify the app is up and running. When you’re ready to move ahead, press Control-C to stop everything. Then, clean up your development environment by entering the following in Terminal:
docker-compose -f docker-compose-develop.yml down
docker volume prune -f
This shuts down any running containers from the compose file. It then removes all containers and network definitions associated with your app. Finally, it cleans up any old Docker storage you can no longer access.
Setting up Vapor and PostgreSQL for Production
There are several changes you can make to your Docker configuration to simplify managing your app in a production environment. In this section, you’ll split your app into a “builder” container and a production image. You’ll also configure the PostgreSQL container to save its database in your host’s file system. This makes your data persist across changes to your app and its configuration.
The Vapor template already contains a Dockerfile suitable for production, named Dockerfile. Open the file in a text editor to inspect it’s contents. It looks something like this:
# 1
FROM swift:5.3-focal as build
# 2
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& rm -rf /var/lib/apt/lists/*
# 3
WORKDIR /build
# 4
COPY ./Package.* ./
RUN swift package resolve
# 5
COPY . .
RUN swift build --enable-test-discovery -c release
# 6
WORKDIR /staging
RUN cp "$(swift build --package-path /build -c release \
--show-bin-path)/Run" ./
RUN [ -d /build/Public ] && \
{ mv /build/Public ./Public && chmod -R a-w ./Public; } \
|| true
RUN [ -d /build/Resources ] && \
{ mv /build/Resources ./Resources && \
chmod -R a-w ./Resources; } || true
# 7
FROM swift:5.3-focal-slim
# 8
RUN export DEBIAN_FRONTEND=noninteractive \
DEBCONF_NONINTERACTIVE_SEEN=true && \
apt-get -q update && \
apt-get -q dist-upgrade -y && \
rm -r /var/lib/apt/lists/*
# 9
RUN useradd --user-group --create-home --system \
--skel /dev/null --home-dir /app vapor
# 10
WORKDIR /app
# 11
COPY --from=build --chown=vapor:vapor /staging /app
# 12
USER vapor:vapor
# 13
EXPOSE 8080
# 14
ENTRYPOINT ["./Run"]
CMD ["serve", "--env", "production", "--hostname",
"0.0.0.0", "--port", "8080"]
- Use version 5.3 of the “swift” image from the Docker Hub repository as the starting point. This container is only for building your app and you may delete it once Docker builds the app.
- Update the system packages, then clean up the working files. This cleanup is a standard operation when building Docker images based on Linux. It reduces the overall size of the image.
- Tell Docker to use /build as its working directory.
- Copy Package.swift and Package.resolved and resolve the app’s dependencies. This allows Docker to cache dependencies between builds, if required.
- Copy your project to the Docker container. Build the project with the release configuration.
- Create a staging directory and copy the executable and any required libraries into it. Also copy the Public directory and Resources directory if they exist. You need to do this if you use Leaf, for example.
- Base your production image on Swift’s slim Docker image. This contains only what’s necessary to run a Swift executable. This is significantly smaller than the image required to build a Swift executable.
- Update all packages, then clean up the working files.
- Create a user to run the executable. This avoids running the executable as root, which can be a security risk.
- Tell Docker to use /app as the working directory.
- Copy files from the builder container.
- Set the user to the one created in step 9.
- Expose port 8080 so clients can connect to the Vapor app in the Docker container.
- Tell Docker how to start the Vapor app.
Next, also in your project’s main directory, open docker-compose.yml in a text editor. This contains a production ready compose file. The contents looks similar to the following:
# 1
version: '3.7'
# 2
volumes:
db_data:
# 3
x-shared_environment: &shared_environment
LOG_LEVEL: ${LOG_LEVEL:-debug}
DATABASE_HOST: db
DATABASE_NAME: vapor_database
DATABASE_USERNAME: vapor_username
DATABASE_PASSWORD: vapor_password
# 4
services:
# 5
app:
# 6
image: tilapp:latest
# 7
build:
context: .
# 8
environment:
<<: *shared_environment
# 9
depends_on:
- db
# 10
ports:
- '8080:8080'
# 11
command: ["serve", "--env", "production", "--hostname",
"0.0.0.0", "--port", "8080"]
# 12
db:
# 13
image: postgres:12-alpine
# 14
volumes:
- db_data:/var/lib/postgresql/data/pgdata
# 15
environment:
PGDATA: /var/lib/postgresql/data/pgdata
POSTGRES_USER: vapor_username
POSTGRES_PASSWORD: vapor_password
POSTGRES_DB: vapor_database
ports:
- '5432:5432'
The compose file also contains services for migrate and revert but these aren’t included here for brevity. Here’s what the compose file does:
- Specify the Docker Compose version.
- Specify a list of volumes used by this application.
- Define a number of shared environment variables, such as database credentials. This allows you to define variables in a single place and share them across different services, such as the main app, migrate and revert. You can specify any variables you require here, such as OAuth Client details.
- Define the services for this application.
- Define a service for the TIL application.
- Specify the image for this service. Docker Compose reuses the images across different services so you don’t have to rebuild it different use cases.
- Specify the build context for the service. By default this uses Dockerfile discussed earlier.
- Specify any environment variables for the service. Include the shared environment variables from step 2.
- Set a dependency on the
dbservice so Docker Compose starts the PostgreSQL container first if not already started. - Expose port 8080 to allow you to connect to the app when it’s running.
- Specify the command to use to start the app. Migrate and revert use different commands.
- Define a service for the PostgreSQL database.
- Use the Alpine
postgresimage. This is a full PostgreSQL database running in a very lightweight container. - Set up a persistent volume from ~/db_data into the container. This causes the data to live in the host system’s file system rather than inside a Docker container and allows it to persist across launches.
- Set the necessary environment variables.
Important: Docker compose doesn’t allow image names to contain capital letters. At the time of writing, the toolbox doesn’t account for this so you may need to lowercase the image names manually, as shown above.
First, ensure that you stop any existing PostgreSQL containers from previous chapters:
docker stop postgres
To bring your app to life, enter the following commands in Terminal:
docker-compose build
docker-compose up -d db
docker-compose up app
These commands build the different containers, start the database in the background and then start the app.
Where to go from here?
You’ve seen some basic recipes for how to run your app in a Docker environment. Because Docker is so flexible, these recipes only scratch the surface of the possibilities available to you. For example, you might want to allow your app to save uploaded files in the host’s file system. Or, you might want to configure the app to run behind an Nginx proxy server to get secure HTTPS access.