Day 4: Setting up CI/CD

Day 4 of 30. Today we're automating deployment before we have much to deploy.

#devops #github-actions #docker

Day 4 of 30. Today we’re automating deployment before we have much to deploy.

One of the most important lessons we’ve learned is that CI/CD is not optional. We want to deploy quickly, reliably and as often as possible. Setting this up before we have anything to deploy might seem backwards since we don’t have a working product yet. Why spend time on deployment pipelines?

One of the reasons for this is because we’ve learned the hard way: the longer you wait with setting an automated deployment pipeline, the harder it gets.

By the end of today, every push to our main branch will automatically build, test, and deploy our app to a real server.

The goal: boring, reliable deploys

We want deployments to be a non-event. The idea is to push code, wait a few moments, and it’s live. There are no manual steps and no manual verifications.

pipeline - CI/CD pipeline
[ ]
Build

Build React frontend and bundle into Spring Boot app

1m 30s
[ ]
Test

Run frontend and backend tests

45s
[ ]
Docker

Build single Docker image for combined service

1m 15s
[ ]
Push

Push image to GitHub Container Registry

20s
[ ]
Deploy

SSH to VPS, pull and restart containers

15s
[ ] Build Build React frontend and bundle into Spring Boot app 1m 30s
[ ] Test Run frontend and backend tests 45s
[ ] Docker Build single Docker image for combined service 1m 15s
[ ] Push Push image to GitHub Container Registry 20s
[ ] Deploy SSH to VPS, pull and restart containers 15s

One deployable unit

We’re keeping things simple: one Docker image that contains everything. The React frontend gets built and bundled into the Spring Boot JAR as static resources. Spring Boot serves them directly - no separate web server, no nginx, no complexity.

Why this approach?

Simpler deployment. One container to build, push, and run. No orchestrating multiple services.

Simpler networking. No CORS issues, no reverse proxy configuration. The API and frontend share the same origin.

Simpler development. Run one thing locally, get everything. No “frontend is on port 3000, backend is on port 8080” confusion.

Good enough for our scale. If we ever need to scale frontend and backend separately, we can split later. Right now, that’s premature optimization.

The GitHub Actions workflow

Here’s our .github/workflows/deploy.yml:

name: Build and Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 25
        uses: actions/setup-java@v4
        with:
          java-version: '25'
          distribution: 'temurin'

      - name: Run API tests
        run: ./gradlew test

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Build and push image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          script: |
            cd /opt/screenshot-api
            docker compose pull
            docker compose up -d

Nothing fancy. It runs tests, builds one Docker image with everything bundled, pushes it to GitHub’s free container registry, then SSHs into our server to pull and restart.

The Dockerfile

One Dockerfile that builds everything:

# Stage 1: Build the frontend
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

# Stage 2: Build the backend (with frontend bundled in)
FROM eclipse-temurin:21-jdk-alpine AS backend-build
WORKDIR /app
COPY --from=frontend-build /frontend/dist src/main/resources/static
COPY src src
RUN ./gradlew bootJar --no-daemon

# Stage 3: Runtime image
FROM eclipse-temurin:21-jre-alpine
COPY --from=backend-build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Three stages:

  1. Frontend build - npm install, npm build, outputs to dist/
  2. Backend build - Copies frontend output into Spring Boot static resources, then builds the JAR
  3. Runtime - Minimal JRE image with just the JAR

CI/CD cost breakdown

cost-breakdown - CI/CD infrastructure costs
Total monthly cost: $4.50/mo
GitHub Actions FREE
GitHub - Free for public repos, generous limits for private
-
Container Registry FREE
GitHub Packages - Free for public images
-
VPS (deployment target)
Hetzner CX22 - 2 vCPU, 4GB RAM, 40GB disk
$4.50
Budget usage $4.50 / $20.00 (23%)
[✓] Well under budget!

Testing the pipeline

We pushed a dummy commit to verify everything works:

  1. GitHub Actions triggered ✓
  2. Tests passed ✓
  3. Docker image built (frontend + backend combined) ✓
  4. Image pushed to registry ✓
  5. SSH deployment succeeded ✓
  6. App running on VPS ✓

Build and starting the whole application too about 4 minutes, end-to-end. This is fast enough for our needs, and we can optimize this later if it bothers us enough.

What we accomplished today

  • Wrote GitHub Actions workflow for CI/CD
  • Created single Dockerfile that bundles frontend into backend
  • Set up Docker Compose on VPS
  • Configured secrets in GitHub
  • Verified the full pipeline works

From now on, shipping is just git push, which requires a bit of discipline, but allows us to ship fast.

Tomorrow: choosing our hosting

On day 5 we’ll dig deeper into our hosting decision. Why a VPS? What are the alternatives? How do we think about cost vs. convenience at this stage?

Book of the day

The Phoenix Project by Gene Kim, Kevin Behr & George Spafford

This novel (yes, a novel about IT operations) transformed how we think about deployment and DevOps. It follows an IT manager at a struggling company and shows how applying manufacturing principles to software delivery can fix broken organizations.

The core insight we got was: work in progress is the enemy. Long-lived branches, manual deployments, and “we’ll automate it later” attitudes create bottlenecks that slow everything down.

Setting up CI/CD on day 4 of a project might seem premature, but our experience and the lessons from this book convinced us that the pain of manual deployment compounds over time, while the investment in automation pays dividends immediately.


Day 4 stats

Hours
█░░░░░░░░░░░░░░
8h
</> Code
█░░░░░░░░░░░░░░
200
$ Revenue
░░░░░░░░░░░░░░░
$0
Customers
░░░░░░░░░░░░░░░
0
Achievements:
[✓] CI/CD pipeline [✓] Auto-deployment [✓] VPS running
╔════════════════════════════════════════════════════════════╗
E

Erik

Building Allscreenshots. Writes code, takes screenshots, goes diving.

Try allscreenshots

Screenshot API for the modern web. Capture any URL with a simple API call.

Get started