Single-Server Deployments with Terraform, Docker Compose, and GitHub Actions
A single cloud server can affordably power a range of services. In many cases, the vertical scalability capabilities of a single host are enough to cover the needs of many business applications for years, if not indefinitely. Deploying on top of plain cloud servers requires more expertise and oversight and has different risks and costs than relying on popular PaaS offerings like Heroku. There are also tools like Dokku that facilitate deploying to a single server with a good developer experience. But let's assume that you don't want to use any of these, but prefer to build your deployment pipeline on top of lower-level building blocks like Terraform, an infrastructure provisioning tool, Docker Compose, a multi-container manager, and GitHub Actions, a CI/CD automation tool. This post shows one way to do that - ok, cheating a little by also relying on Docker Hub, S3, Route53, and Let's Encrypt for convenience, but still keeping recurring hosting costs minimal.
Motivation
It can be hard to justify rationally deploying services on top of raw cloud capacity and relatively low-level orchestration and infrastructure tools. But it can be fun and educational and give a warm fuzzy feeling that you're supposedly minimizing your hosting costs, provided you don't put any price on the time you spend building, maintaining, and monitoring custom deployment pipelines - not to mention any incident mitigations at odd hours. In principle, it can be suitable even for production systems, provided that an occasional loss of availability or the risk of losing a few hours' worths of data are not deal-breakers.
I used to build desktop Linux systems from components, foolhardily combining hardware components with experimental Linux support. Most of the time, I even got them to work after spending hours compiling kernel modules and tinkering with Xorg settings. Albeit occasionally frustrating, I got comfortable on the command line and developed a liking for a deeper understanding of the complexity of Linux systems.
Another reason is that, while managed services are generally very reliable, sometimes they fail, and there's very little you can do besides sending filing support tickets and hoping that someone does something. Managing infrastructure and services yourself lets you play the hero when (not if) it goes down. Root access to all parts of the system makes it possible to customize it to the degree that is not necessarily possible in managed services. For example, you may want to add a custom PostgreSQL extension not supported by a hosting provider.
Overview
The deployment setup in this post uses Terraform to create:
- a cloud server at UpCloud, a hosting provider with good support and fast servers,
- an A DNS record at Route53 pointing to the cloud server IP address,
- a CNAME DNS record with a different hostname pointing to the hostname of the cloud server, and
- an S3 bucket to store PostgreSQL database backups.
The base system for the cloud server is Ubuntu 20.04, on top of which an initialization script installs sshguard, Munin, Docker, and Docker Compose.
Docker Hub hosts the image for a custom Node.js application.
After the server is up and running, a GitHub Actions workflow syncs the Docker Compose configuration file with three services (Nginx, Node.js, and PostgreSQL) and runs Docker Compose remotely.
Let's Encrypt's certbot Docker image manages SSL Certificates.
Creating S3 Bucket for Terraform State
Terraform transforms declarative definitions into a series of API commands to bring up cloud resources like servers and DNS records. It uses a state file to track metadata and improve performance between deployments. Since this setup uses GitHub Actions to run Terraform and GitHub Actions runners do not have a persistent file system, an S3 bucket serves instead as a place to store the state file. This bucket must be created separately as a one-off manual step with an IAM user allowed to create S3 buckets.
You can create an S3 bucket for the Terraform state file through AWS Console or by running Terraform locally. To create an S3 bucket locally, we need one Terraform definition file main.tf
. The definition file below uses the AWS provider to interact with AWS API and specifies a single aws_s3_bucket resource:
You can run Terraform locally with Terraform's Docker image. Assuming AWS credentials are created (see Configuring AWS CLI), you can expose them to the Docker container by volume-mounting ~/.aws
. First, you must initialize a Terraform working directory by running the init command.
The command above creates a Dependency Lock File and installs the required dependencies. After initialization succeeds, you can execute the apply command to create the bucket:
docker run -it \
-v $(pwd):/workspace
-v ~/.aws:/root/.aws
-w /workspace \
hashicorp/terraform:1.2.5 apply
Terraform will present the plan and requires you to confirm it by typing "yes" before proceeding. Provided that everything went smoothly until now, you should see the following message:
aws_s3_bucket.bucket: Creating...
aws_s3_bucket.bucket: Creation complete after 6s [id=my-project-tf-state]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Server Initialization Script
After provisioning a cloud server with a base system, you can install more packages and configurations with an initialization script. The initialization script below first installs sshguard, Munin, Docker, and Docker Compose. Then it tweaks the unattended-upgrades
configuration to reboot the server if needed and prune unused packages to conserve disk space.
Creating Server and DNS Records with Terraform
With the S3 bucket for Terraform state created, you can proceed to create a server, DNS records, and an S3 bucket to store backups. The s3 backend stores Terraform state to the S3 bucket. In addition to the AWS provider, I'll be using the UpCloud Terraform provider and borrowing the server definitions from UpCloud's Terraform starter tutorial.
Organizing Terraform definitions in multiple files (and folders) is a good practice in a larger project. For this small setup, it suffices to place the configuration in two files provider.tf
and server1.tf
in the folder terraform
:
The resource definition file main.tf
creates three resources:
- The resource upcloud_server adds a cloud server at UpCloud according to the specified size of the instance (CPU, memory, and storage), the base system, and the SSH keys (the Terraform variables
SSH_PRIVATE_KEY
andSSH_PUBLIC_KEY
) to connect to the instance and an initialization script. - The resource aws_route53_record adds an A DNS record pointing at the server instance public network interface at Route53.
- The resource aws_s3_bucket adds an S3 bucket to store database backups.
In addition to volume-mounting AWS credentials, you need to pass UpCloud API credentials in UPCLOUD_USERNAME
and UPCLOUD_PASSWORD
environment variables and SSH credentials in TF_VAR_OPENSSH_PRIVATE_KEY
and TF_VAR_OPENSSH_PUBLIC_KEY
when applying:
Running Terraform in GitHub Actions
Changes to infrastructure are less frequent than application updates, but due to their possible destructive nature, it's good to keep the number of moving parts to a minimum and dedicate a separate repository for Terraform configuration files. This standardizes the process of making changes to the infrastructure, that is, always running the same version of Terraform. It also allows other contributors to open PRs to make changes to the infrastructure without handing out cloud provider credentials.
Creating a script to invoke Terraform keeps GitHub Actions workflows tidy and makes it easier to run it in the same way on your local computer. The script below, unlike before, passes AWS credentials in the environment and does not volume-mount the folder ~/.aws.
Before approving a PR containing changes to the infrastructure it's good to check that the Terraform plan is sensible. The GitHub Actions workflow below triggers when a PR is opened, reopened or synchronized and posts the output of terraform plan as a PR comment using the action peter-evans/create-update-comment:
The workflow posts the terraform plan output as a PR comment, but does not preserve it for terraform apply
. Running terraform apply
later may thus have other effects in principle.
If the plan in the comment looks good, you can use the GitHub Actions workflow below that triggers when a new commit is pushed to the main branch to run terraform apply
.
After merging the PR, another workflow run applies changes to the infrastructure.
For increased peace of mind, you can tweak the GitHub Actions workflow files pr.yaml
and main.yaml
to store the plan generated by terraform plan
in an S3 bucket and load it before applying. The Terraform documentation also has a guide on how to integrate Terraform into GitHub Actions workflows.
You can test that the server has been correctly provisioned by opening an SSH connection with the private key:
Simple Node.js Application
As a proof of concept, I'll include a minimal Node.js application to build and deploy as a custom Docker image. The application starts an HTTP server, connects to a PostgreSQL database, and responds to HTTP requests with a timestamp coming from the database.
The two-stage Dockerfile copies the files needed to install dependencies and to transpile TypeScript sources:
To keep track of which version of the application is running and to roll back faster in case of trouble, you can include the git revision in the Docker image tag when building:
Docker Compose Configuration
With the server provisioned and Docker installed, the next step is to provide a configuration for Docker Compose. The example configuration file below defines three services:
- PostgreSQL, a database for the application.
- Node.js application in a custom Docker image
app
. - Nginx, a reverse proxy, serves static files to verify Let's Encrypt certificate.
The Node.js application has two replicas so it can be restarted them without downtime.
The PostgreSQL data directory is volume-mounted so that it survives reboots. The Nginx container has three volume mounts: one for Let's Encrypt certificates, another for Let's Encrypt challenges, and the third for reverse proxy configuration.
The configuration references three environment variables:
APP_IMAGE_NAME
: the name of the Docker image to start, e.g.,tlaitinen/app:18c1fc796e1ac8fc14c93da0ef396dffb9ba7efe
with a default value so that it's possible to rundocker compose ps
anddocker compose logs
without specifying an image name.APP_PGUSER
,APP_PGPASSWORD
, andAPP_PGDATABASE
: the PostgreSQL credentials the application uses to connect to the database.
Since you may want to connect to the server from your local computer and also from GitHub Actions runner, it's convenient to define a script that passes the relevant environment variables. The script below logs into Docker Hub before running docker compose
and prunes unused Docker images to safe disk space.
Building Docker Image in GitHub Actions
The Docker image for the application should be built whenever any of the assets included in the build stage change. In principle, we could detect this with git diff
but in practice, it's easier and simpler to rebuild the image whenever the git repository's main branch's HEAD moves. After the image is built it needs to be pushed to Docker Hub. Then the new application can be deployed by instructing Docker Compose to pull and start a new image. The GitHub Actions workflow file below runs whenever someone pushes to the main
branch. The workflow references four secrets:
DOCKERHUB_USERNAME
is your Docker Hub ID, not really a secret, but store in repo secrets for convenience.DOCKERHUB_TOKEN
is an API credentials token for your Docker Hub ID.APP_IMAGE_NAME
is the name of the Docker image to start.APP_PGUSER
is the name of the role to connect to the PostgreSQL database with. I don't cover database schema creation here so I set it topostgres
.APP_PGPASSWORD
is the password for the PostgreSQL role. In this experiment I set it to matchPOSTGRES_PASSWORD
topostgres
Docker container is created with.SERVER_HOSTNAME
: the hostname of the server (app.terolaitinen.fi), not a secret, but it's convenient to store it in a secret to share it in multiple workflow definitions.
After pushing a commit to the main
branch, the workflow packages the TypeScript application in a Docker image and deploys it to the provisioned server.
You can now verify that the Docker containers are running in the server.
Reverse Proxy Configuration and SSL Certificate
The deployment setup in this article uses nginx
as the reverse proxy for the application and also as a static file server when requesting SSL certificates. The script below connects to the server through SSH and runs Certbot in Docker to request or to renew an SSL certificate. After that, it writes the Nginx configuration file to and restarts or reloads the reverse proxy. It expects two environment variables:
SERVER
: the hostname of the server,EMAIL
: the email address where Let's Encrypt sends
The GitHub Actions workflow below runs every day at midnight (UTC), checks out the repository, configures the SSH private key and feeds the secrets SERVER_HOSTNAME
and LETSENCRYPT_EMAIL
when it runs the script above:
The workflow can also be manually dispatched (see workflow_dispatch) so that you can configure the reverse proxy and request an SSL certificate immediately after configuring the workflow.
After a successful run, you should be able to make an HTTPS request and retrieve the current time from the PostgreSQL server through the reverse proxy and the Node.js application.
Storing Backups
Always sometimes, you end up losing data. It can happen due to a bug in the application, accidental deletions, hardware failure, or a third party. Backing up data keeps you in business when something happens and helps you sleep better in the meanwhile. In this deployment setup, I focus on taking daily backups of the PostgreSQL database. However, if the budget permits, enabling full file system backups of the server may simplify and speed up the recovery process.
In case losing up to a day's worth of data is unacceptable, you probably stopped reading a long time ago and are already using managed PostgreSQL services like Aiven or AWS RDS, but another fun option would be to configure continuous archiving and point-in-time recovery.
Let's assume that daily backups are enough. The script below connects to the server through SSH, runs pg_dump and pipes the output to gzip
and then to the AWS command line client, streaming the database dump in SQL format to the S3 bucket.
The GitHub Actions workflow below runs every day at midnight (UTC), checks out the repository, configures the SSH private key and feeds the appropriate secrets to the script above:
Conclusions
A single server can often be sufficient to power a wide range of services. You can optimize your recurring hosting costs by building on raw server capacity while leveraging cost-efficient automation (Terraform, Docker Compose, and GitHub Actions) and managed services (AWS Route53 and S3) where appropriate. Trying out this deployment setup was an itch I long wanted to scratch, but while writing this article, I realized that it would've been better to split it into shorter, more focused pieces. Covering all relevant aspects of a well-planned and automated single-server deployment in one article was overly ambitious, and this article falls short. For a proper server setup, you should have adequate monitoring, alerting (e.g., when disk space is low or logs are showing errors), storing server logs, and health checks. For example, GoAccess could be integrated into this setup to view server stats in real-time, and Munin could give an overview of system resources. GitHub Actions could work as a simple basis to implement health checks and alert by email on failed workflow runs. Security considerations alone would warrant a more extended discussion.
Using these building blocks, you have a lot of flexibility in tailoring the setup to your requirements. There's very little magic after the system is provisioned, so it's easy to understand it and tweak it as needed. As long as your backups processes are properly configured and occasional downtime is not a deal breaker, you can cost-effectively host a range of services on a single server.