Day 4: Setting up CI/CD
Day 4 of 30. Today we're automating deployment before we have much to deploy.
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.
Build React frontend and bundle into Spring Boot app
1m 30sRun frontend and backend tests
45sBuild single Docker image for combined service
1m 15sPush image to GitHub Container Registry
20sSSH to VPS, pull and restart containers
15sOne 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:
- Frontend build - npm install, npm build, outputs to
dist/ - Backend build - Copies frontend output into Spring Boot static resources, then builds the JAR
- Runtime - Minimal JRE image with just the JAR
CI/CD cost breakdown
Testing the pipeline
We pushed a dummy commit to verify everything works:
- GitHub Actions triggered ✓
- Tests passed ✓
- Docker image built (frontend + backend combined) ✓
- Image pushed to registry ✓
- SSH deployment succeeded ✓
- 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.