Table of contents
Open Table of contents
Introduction
Deploying a full-stack application (web, backend, and database) can be complex and time-consuming, especially with platforms like AWS or Google Cloud. These platforms often involve intricate setups, extensive configurations, and complex terminology that many developers find cumbersome. DigitalOcean’s App Platform offers a simpler, more streamlined process. In this guide, we’ll walk through deploying a scalable full-stack application using a Terraform script. This setup ensures ease of deployment and scalability, all while staying within a budget of $40/month, making it a cost-effective solution for production-ready deployments. Just FYI, I am not affiliated with DigitalOcean in any way.
Prerequisites
Before we start, ensure you have the following:
- A DigitalOcean account
- Terraform installed on your machine
- Docker installed on your machine (needed for running commands to grant database permissions)
- A DigitalOcean API token to be used by terraform
- A DigitalOcean container registry
- Set up
DIGITALOCEAN_ACCESS_TOKEN
as a repository secret in GitHub for the GitHub Actions to work
Additionally, create a terraform.tfvars.json
file with the following content:
{
"do_token": "your-do-token"
}
Replace your-do-token with your DigitalOcean API token.
Setting up your terraform script
Here’s the Terraform script we’ll be using. This script automates the creation of a PostgreSQL database cluster, sets up a database user with permissions, and deploys a web and API service on DigitalOcean’s App Platform.
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.39.1"
}
null = {
source = "hashicorp/null"
version = "~> 3.1.0"
}
}
}
provider "digitalocean" {
token = var.do_token
}
# This will be read from the terraform.tfvars.json file.
variable "do_token" {}
# Variable setup. Change the values to match your needs. Remember to change your-app-name to your real app name.
variable "region" {
description = "DO region"
type = string
default = "fra1"
}
variable "services_names" {
description = "Name of the services you want to create"
type = object({
web = string
api = string
})
default = {
"web" = "web"
"api" = "api"
}
}
variable "app_name" {
description = "Name of the app"
type = string
default = "your-app-name"
}
variable "environments" {
description = "Map of environment names and their attributes"
type = map(any)
default = {
"prod" = {
"domain" : null,
"db" : {
"production" : true,
"size" : "db-s-1vcpu-1gb",
"node_count" : 1
},
"api" : {
"instance_count" : 1,
"size_slug" : "apps-s-1vcpu-0.5gb",
"port" : 80
},
"web" : {
"instance_count" : 1,
"size_slug" : "basic-xxs",
"port" : 80
}
}
}
}
resource "digitalocean_database_cluster" "db-cluster" {
for_each = var.environments
name = "${each.key}-cluster"
engine = "pg"
version = "16"
size = var.environments[each.key].db.size
region = var.region
node_count = var.environments[each.key].db.node_count
}
# Create a designated user for the api database.
resource "digitalocean_database_user" "api-user" {
for_each = var.environments
cluster_id = digitalocean_database_cluster.db-cluster[each.key].id
name = "${var.services_names.api}-user"
}
resource "digitalocean_database_db" "api-db" {
for_each = var.environments
cluster_id = digitalocean_database_cluster.db-cluster[each.key].id
name = "${var.services_names.api}-db"
}
# Grant permissions to the user on the database. This part requires you to have docker installed and running.
resource "null_resource" "grant_permissions" {
for_each = var.environments
provisioner "local-exec" {
command = <<EOT
docker run --rm -e PGPASSWORD=${digitalocean_database_cluster.db-cluster[each.key].password} postgres:13 psql -h ${digitalocean_database_cluster.db-cluster[each.key].host} -U ${digitalocean_database_cluster.db-cluster[each.key].user} -p ${digitalocean_database_cluster.db-cluster[each.key].port} -d ${digitalocean_database_db.api-db[each.key].name} -c "GRANT ALL PRIVILEGES ON DATABASE \"${digitalocean_database_db.api-db[each.key].name}\" TO \"${digitalocean_database_user.api-user[each.key].name}\"; GRANT ALL PRIVILEGES ON SCHEMA public TO \"${digitalocean_database_user.api-user[each.key].name}\";"
EOT
environment = {
PGPASSWORD = digitalocean_database_cluster.db-cluster[each.key].password
}
}
depends_on = [
digitalocean_database_cluster.db-cluster,
digitalocean_database_user.api-user,
digitalocean_database_db.api-db
]
}
# Create a firewall rule for the database cluster, only allowing the app to connect.
# You can also add your ip address here if you want to be able to access the database from your machine.
resource "digitalocean_database_firewall" "db-cluster-fw" {
for_each = var.environments
cluster_id = digitalocean_database_cluster.db-cluster[each.key].id
rule {
type = "app"
value = digitalocean_app.do-app[each.key].id
}
depends_on = [null_resource.grant_permissions]
}
resource "digitalocean_app" "do-app" {
for_each = var.environments
lifecycle {
ignore_changes = [
# Ignore this field since this will change after creation.
spec.0.features,
# Ignore this field since this will change after creation.
spec.0.region,
# Ignore these blocks since they will be controlled entirely by CI/CD instead.
spec.0.service.0.image,
spec.0.service.1.image
]
}
spec {
name = "${var.app_name}-${each.key}"
region = var.region
# Only create a domain if it's set in the environment.
dynamic "domain" {
for_each = var.environments[each.key].domain != null ? [1] : []
content {
name = var.environments[each.key].domain
}
}
alert {
rule = "DEPLOYMENT_FAILED"
}
service {
name = var.services_names.api
instance_count = var.environments[each.key].api.instance_count
instance_size_slug = var.environments[each.key].api.size_slug
# This is a starting hello-world kind of image to be used for the service. You will control this with GitHub action CI/CD instead.
image {
registry_type = "DOCKER_HUB"
repository = "nginx"
tag = "latest"
}
# Don't forget to change the port so that it matches the port your service is exposing.
http_port = var.environments[each.key].api.port
env {
key = "DB_PASSWORD"
value = digitalocean_database_user.api-user[each.key].password
}
env {
key = "DB_HOST"
value = digitalocean_database_cluster.db-cluster[each.key].host
}
env {
key = "DB_PORT"
value = digitalocean_database_cluster.db-cluster[each.key].port
}
env {
key = "DB_NAME"
value = digitalocean_database_db.api-db[each.key].name
}
env {
key = "DB_USER"
value = digitalocean_database_user.api-user[each.key].name
}
}
service {
name = var.services_names.web
instance_count = var.environments[each.key].web.instance_count
instance_size_slug = var.environments[each.key].web.size_slug
image {
registry_type = "DOCKER_HUB"
repository = "nginx"
tag = "latest"
}
http_port = var.environments[each.key].web.port
}
database {
name = digitalocean_database_db.api-db[each.key].name
db_name = digitalocean_database_db.api-db[each.key].name
cluster_name = digitalocean_database_cluster.db-cluster[each.key].name
production = var.environments[each.key].db.production
}
# Starter ingress, which exposes your app on the root of the domain, and the api on /api.
ingress {
rule {
component {
name = var.services_names.api
}
match {
path {
prefix = "/api"
}
}
}
rule {
component {
name = var.services_names.web
}
match {
path {
prefix = "/"
}
}
}
}
}
}
Explanation of the Terraform Script
Variables
do_token
: Your DigitalOcean API token.region
: The region where the resources will be created. The default is set to fra1.services_names
: The names of the web and API services.app_name
: The name of your application.environment
: A map defining the different environments (e.g., prod) with their respective configurations.
Resources
- Database Cluster: Creates a PostgreSQL database cluster with the specified configurations.
- Database User: Creates a database user for the API service.
- Database: Creates a database for the API service.
- Grant Permissions: Uses a Docker container to connect to the database and grant necessary permissions to the user.
- Database Firewall: Sets up a firewall to allow only the App Platform to access the database.
- App: Deploys the web and API services to the DigitalOcean App Platform with the specified configurations.
Env variables
All required information for connecting to the created database will be set as environment variables in the api
service automatically.
Running the terraform script
- Initialize Terraform: Run the following command to initialize your Terraform configuration.
terraform init
- Apply the Configuration: Apply the configuration to create your resources.
terraform apply
- Review and Confirm: Review the plan and confirm to proceed with the resource creation.
GitHub actions for CI/CD
To automate the build and deployment process, you can use GitHub Actions. Below I will provide an example for a GitHub action to handle the web part of the code. Depending on what backend language you are using, you can set up something similar to handle the backend part as well. This GitHub Actions script is designed for a monorepo setup, expecting the frontend code to be in the web
directory.
GitHub Actions Workflow for Building and triggering deployment
This GitHub Actions workflow is designed to build and test your web service on every push to the main branch and on every pull request. For pull requests, it runs tests without deploying. On a push to the main branch, it also builds the Docker image, pushes it to the DigitalOcean Container Registry, and triggers a deployment.
Create a .github/workflows/web.yml
file in your repository:
name: web
on:
push:
branches:
- main
paths:
- .github/workflows/web.yml
- web/**
pull_request:
branches:
- main
paths:
- .github/workflows/web.yml
- web/**
concurrency:
group: web-${{ github.event_name }}
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
permissions: write-all
env:
SERVICE: web
# Change this to your actual registry name
REGISTRY_NAME: your-registry-name
outputs:
version: ${{ steps.service_version.outputs.version }}
steps:
- name: Checkout sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn"
cache-dependency-path: ${{ env.SERVICE }}/yarn.lock
- name: Build ${{ env.SERVICE }}
working-directory: ${{ env.SERVICE }}
run: |
yarn install
yarn test
yarn build
- name: Install doctl
if: github.event_name == 'push'
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- name: Generate service version
if: github.event_name == 'push'
id: service_version
uses: paulhatch/semantic-version@v5.4.0
with:
tag_prefix: ${{ env.SERVICE }}-v
# "major-web-bump" in the commit header would bump a major version, for example.
major_pattern: "(major-${{ env.SERVICE }}-bump)"
minor_pattern: "(minor-${{ env.SERVICE }}-bump)"
- name: Log in to DigitalOcean Container Registry with short-lived credentials
if: github.event_name == 'push'
run: doctl registry login --expiry-seconds 600
- name: Build and push docker image
if: github.event_name == 'push'
working-directory: ${{ env.SERVICE }}
run: |
image_name=registry.digitalocean.com/${{ env.REGISTRY_NAME }}/${{ env.SERVICE }}:${{ steps.service_version.outputs.version }}
docker build . -t $image_name
docker push $image_name
# Create a Git Tag to match the exact tag of the docker image you have created.
- name: Create tag
if: github.event_name == 'push'
uses: actions/github-script@v7
with:
script: |
github.rest.git.createRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: 'refs/tags/${{ env.SERVICE }}-v${{ steps.service_version.outputs.version }}',
sha: context.sha
})
deploy:
if: github.event_name == 'push'
needs: build
uses: ./.github/workflows/deploy-service.yml
with:
service: web
version: ${{ needs.build.outputs.version }}
env: prod
secrets: inherit
Explanation of the web workflow
This GitHub Actions script is designed for a monorepo setup, expecting the frontend code to be in the web directory. Here’s a breakdown of what each part of the workflow does:
- Triggers: The workflow is triggered on pushes and pull requests to the main branch, but only if changes are made in the .github/workflows/web.yml file or the web directory.
- Concurrency: Ensures that only one instance of the workflow runs at a time for the same event type.
- Jobs: The script defines two jobs: build and deploy.
Build job
- Checkout Sources: Checks out the repository’s code.
- Setup Node.js: Sets up Node.js version 20 and caches dependencies based on the yarn.lock file.
- Build: Installs dependencies, runs tests, and builds the project.
The below part only runs in case code has been pushed, not in case of pull requests. Above part will also run on pull requests.
- Install doctl: Installs the DigitalOcean CLI (doctl) for authentication.
- Generate Service Version: Uses semantic-version to generate a version tag based on commit messages.
- Log in to DigitalOcean Container Registry: Logs into the DigitalOcean Container Registry with short-lived credentials.
- Build and Push Docker Image: Builds the Docker image for the web service and pushes it to the registry.
- Create Tag: Creates a Git tag for the new version.
Deploy job
This job is also only triggered on push events and depends on the successful completion of the build job. It uses a separate workflow file (deploy-service.yml) to deploy the built Docker image to the DigitalOcean App Platform.
Deployment workflow
Create a .github/workflows/deploy-service.yml
file in your repository:
name: deploy-service
run-name: Deploy ${{ inputs.service }} ${{ inputs.version }} to ${{ inputs.env }}
on:
workflow_call:
inputs:
service:
required: true
type: string
version:
required: true
type: string
env:
required: true
type: string
workflow_dispatch:
inputs:
env:
type: environment
description: Environment to deploy to
service:
type: choice
options:
- api
- web
description: Service name
required: true
version:
required: true
description: Image version (e.g. 0.0.1)
type: string
# This is because the App Platform in DO can't handle simultaneous deployments for different parts of the App as of now.
concurrency:
group: deploy-${{ inputs.env }}
cancel-in-progress: false
jobs:
deploy-service:
runs-on: ubuntu-latest
environment: ${{ inputs.env }}
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Deploy service
uses: digitalocean/app_action@v1.1.6
with:
app_name: your-app-name-${{ inputs.env }}
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
images: '[
{
"name": "${{ inputs.service }}",
"image": {
"registry_type": "DOCR",
"repository": "${{ inputs.service }}",
"tag": "${{ inputs.version }}"
}
}
]'
Setting up secrets
Ensure you have set up a DIGITALOCEAN_ACCESS_TOKEN
secret in your GitHub repository, available to the GitHub actions. This can be the same token as used above in Terraform, or a separate one if you would like. Best practice would be to separate them, as the GitHub action use-case will require less permissions to function.
Additional steps
Piping Logs to an External Service
To pipe logs to an external service like Logtail:
-
Create an account on Logtail and create a new source to get your source token.
-
Modify the Terraform Script to include Logtail configuration:
# existing configurations...
variable "logtail_token_api" {}
resource "digitalocean_app" "do-app" {
for_each = var.environments
spec {
# existing configurations...
service {
name = var.services_names.api
# other configurations...
log_destination {
name = "ApiLogs"
logtail {
token = var.logtail_token_api
}
}
}
# other services...
}
}
- Add
logtail_token_api
to yourterraform.tfvars.json
.
{
"do_token": "your-do-token",
"logtail_token_api": "your-logtail-token"
}
Creating a Dev Environment
To create a dev environment, you can add a new entry to the environments variable:
variable "environments" {
description = "Map of environment names and their attributes"
type = map(any)
default = {
"prod" = {
"domain" : null,
"db" : {
"production" : true,
"size" : "db-s-1vcpu-1gb",
"node_count" : 1,
},
"api" : {
"instance_count" : 1,
"size_slug" : "apps-s-1vcpu-0.5gb",
"port" : 80
},
"web" : {
"instance_count" : 1,
"size_slug" : "basic-xxs",
"port" : 80
}
},
"dev" = {
"domain" : null,
"db" : {
"production" : false,
"size" : "db-s-1vcpu-1gb",
"node_count" : 1,
},
"api" : {
"instance_count" : 1,
"size_slug" : "apps-s-1vcpu-0.5gb",
"port" : 80
},
"web" : {
"instance_count" : 1,
"size_slug" : "basic-xxs",
"port" : 80
}
}
}
}
This will automatically create all resources in both test and production, with the given specifications.
Adding a domain
Simply give the domain attributes a value and configure the rest through the DO portal. You might have to add spec.0.domain
to the ignore_changes
list after doing that.
Scaling the API Vertically and Horizontally
Vertical Scaling
Modify the size_slug in your Terraform script:
"size_slug" : "apps-s-1vcpu-1gb"
Horizontal Scaling
Modify the instance_count
in your Terraform script:
"instance_count": 2
Conclusion
By following these steps, you can easily deploy and manage a full-stack application on DigitalOcean’s App Platform, benefiting from the simplicity and cost-effectiveness of DigitalOcean compared to AWS or Google Cloud. This setup ensures that your application is production-ready and scalable while keeping costs under $40 per month.