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.
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!