Deploying a web app on DigitalOcean

Cyrill JaunerCyrill Jauner
6 min read

In recent weeks, I've spent some of my free time working on a small web app. The goal was to create an implementation of the game Battleship and deploy it to a cloud platform. I also wanted to explore a few technologies and platforms along the way, mainly based on personal interests:

  • Vite.js for the frontend app

  • Node.js for the backend app

  • GitHub Actions for CI/CD

  • DigitalOcean for hosting the apps

In this article, I'll discuss the project's setup. How is the repository organized? How are the apps built? How is GitHub integrated with DigitalOcean? Hopefully, you'll find answers to these questions here ๐Ÿ˜

Backend and frontend together in a monorepo

The application consists of a frontend and a backend app. The source code for both apps and all configuration files are organized in a monorepo, which was very convenient for development. I didn't have to switch between different repositories and always had a clear overview. Additionally, since both apps are written in TypeScript, I could easily move shared code between frontend and backend into a top-level directory. Of course, there are more elegant solutions - like writing an OpenAPI spec and generating code -but for my simple use case, a shared file was entirely sufficient.

The following image shows the top-level structure of the repository, including directories for the apps and platform-specific configuration files.

The structure of the repository with a description of the directories.

Using Github actions for continuous integration

Since the repository is hosted on GitHub, it made sense to use GitHub Actions. This allowed me to automate various tasks. I set up two pipelines (called workflows in GitHub terminology).

The first "development" workflow is triggered whenever a pull request is created or modified. Even though I'm the only developer, I find pull requests helpful for maintaining clarity, and I also prefer to squash related changes when merging into the main branch. GitHub makes this process quite comfortable.

To trigger the workflow at the right time, an "on" block is added to the workflow file. Its configuration is quite self-explanatory. In general, I found the workflow syntax very intuitive, even though it was my first time using this feature.

name: Development

on:
  pull_request:
    types:
        - opened
        - edited
        - synchronize
        - reopened
  workflow_call:

In the "jobs" configuration, I specified all the steps of the workflow. Each job runs in parallel. For my setup, I created one job for the frontend and another for the backend. The steps are nearly identical, so hereโ€™s an example configuration for the backend job.

backend:
  runs-on: ubuntu-latest
  timeout-minutes: 10
  steps:
    - name: checkout repository
      uses: actions/checkout@v4.2.2
    - name: setup node
      uses: actions/setup-node@v4.1.0
      with:
        node-version: 22
    - name: install dependencies
      run: |
        cd backend
        npm install
    - name: lint
      run: |
        cd backend
        npm run lint
    - name: build
      run: |
        cd backend
        npm run build
    - name: unit tests
      run: |
        cd backend
        npm run test

Besides the "development" workflow, I also created a "main" workflow, which triggers whenever there's a push to the main branch. In this case, I want to run builds and tests again - essentially what the "development" workflow already does. Luckily, these steps don't have to be duplicated; workflows can call each other. In the "main" workflow, I added a "uses" step to achieve this.

  build-and-test:
    uses: jaunerc/battleships/.github/workflows/development.yml@main

Using Github actions and DigitalOcean for Continuous Delivery

The "main" workflow handles additional tasks beyond building and testing. Ultimately, the applications need to be deployed on DigitalOcean. I'll explain what's required for this in the next section.

Create and upload docker images

Each workflow execution creates Docker images for the backend and frontend apps, which are then uploaded to GitHub's own container registry, GHCR. Authentication requires a token, created through GitHub and stored in the Actions project settings. The login process in the workflow looks like this:

- name: Log in to GitHub container registry
  uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.PERSONAL_ACCESS_TOKEN }}

Creating and uploading the images happens in a separate step. Each app has its own Dockerfile passed as a parameter.

- name: Build and push container image to registry (backend)
  id: push
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: ghcr.io/jaunerc/battleships/backend:${{ github.sha }}
    file: ./deployment/backend/Dockerfile

- name: Build and push container image to registry (frontend)
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: ghcr.io/jaunerc/battleships/frontend:${{ github.sha }}
    file: ./deployment/frontend/Dockerfile

The Dockerfile itself requires minimal configuration. The key steps involve copying the application and shared files into the image. Here's an example of the frontend app Dockerfile:

FROM node:22-alpine

WORKDIR /home/node/app

COPY ./frontend/package*.json .

RUN npm install

COPY ./shared ../shared
COPY ./frontend .

RUN npm run build

CMD ["npm", "run", "prod"]

Deploy the apps to DigitalOcean

In the final step, the created image is uploaded and deployed to DigitalOcean. Again, predefined actions simplify this process. We specify the image tag and provide authentication tokens for GHCR and DigitalOcean.

- name: checkout repository
  uses: actions/checkout@v4.2.2

- name: Deploy to DigitalOcean
  uses: digitalocean/app_action/deploy@v2
  env:
    SAMPLE_DIGEST: ${{ steps.push.outputs.digest }}
    IMAGE_TAG: ${{ github.sha }}
    GHCR_READ_PACKAGE_TOKEN: ${{ secrets.GHCR_READ_PACKAGE_TOKEN }}
  with:
    app_spec_location: '.do/app.yaml'
    token: ${{ secrets.DIGITALOCEAN_API_TOKEN }}

I've summarized the entire deployment step in the graphic below. Most tasks are handled by GitHub tools. For the DigitalOcean API, we only need a token, which can be created directly in the DO Control Panel.

I'd like to briefly discuss the app-spec provided during deployment. It's a file that abstracts Kubernetes configurations. DigitalOcean uses this file to set up apps on its App Platform, with the available configurations well-documented.

This project required both a backend and a frontend app, so the configuration defines two "services" accordingly. Additionally, I specified the path and port where each app can be accessed.

name: battleships
services:
  - name: backend
    http_port: 3001
    routes:
      - path: /backend
    image:
      registry_type: GHCR
      registry: jaunerc
      repository: battleships/backend
      tag: ${IMAGE_TAG}
      registry_credentials: "jaunerc:${GHCR_READ_PACKAGE_TOKEN}"
  - name: frontend
    http_port: 5173
    routes:
      - path: /
    image:
      registry_type: GHCR
      registry: jaunerc
      repository: battleships/frontend
      tag: ${IMAGE_TAG}
      registry_credentials: "jaunerc:${GHCR_READ_PACKAGE_TOKEN}"

After a successful pipeline run, both apps are created and publicly available ๐Ÿ˜„

Availability and cost management

The deployed apps on DigitalOcean are paid services. There are different VM sizes at various prices. For this project, I chose the cheapest option, costing $5 per month per app. Together, frontend and backend apps would total $10 per month. However, billing occurs based on actual runtime rather than upfront payment. Since the project isn't continuously running, my actual costs have been much lower. This makes the App Platform quite affordable to try out. Additionally, new users receive free credit, at least as of January 2025.

Conclusion

I worked with DigitalOcean for the first time and with GitHub Actions for the first time in a long while. I was pleasantly surprised by how simple the integration between GitHub and DigitalOcean turned out to be. Additionally, GitHub Actions has a lot of excellent documentation.

However, managing a large number of different configuration files was somewhat cumbersome. While the concept of "Infrastructure-as-Code" is very practical, it leads to numerous files with varying syntaxes, making it sometimes challenging to maintain an overview.

Thanks for reading, and I hope you found some interesting points!

GitHub project link

0
Subscribe to my newsletter

Read articles from Cyrill Jauner directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Cyrill Jauner
Cyrill Jauner