Introduction to Github Actions
The clearest example I can give you of how this plays out in practice is our ATS (Applicant Tracking System). It's a React frontend talking to a Java 25 Spring Boot 4 API, and for a long time its pipeline was a mess of shell scripts triggered by a webhook and a fragile sequence of manual restarts on the server. What we ended up building at that time (now we are using Coolify similar what I wrote here https://www.linkedin.com/pulse/deploying-java-spring-boot-ats-stack-coolify-jose-adrian-aleman-rojas-jrsxe ) with GitHub Actions was cleaner, faster, and something any new team member can actually read without a guided tour. I'll use it as the thread through this whole article. What It Actually Is GitHub Actions is GitHub's built-in CI/CD system. It lives in your repo, it runs on GitHub's infrastructure (or your own runners if you need that), and you configure it entirely in YAML files that sit in .github/workflows/ . That's really it. No separate server to babysit. No webhook configuration. No admin panel you need elevated permissions to touch. The mental model is pretty simple once it clicks: Events trigger things. A push to main , a pull request, a new release tag, a scheduled cron. Even a manual button click. Workflows are the YAML files that define what happens when an event fires. Jobs are the units of work inside a workflow. They run in parallel by default, which is something I didn't realize at first and it bit me once. Steps are the individual commands or actions inside a job. Actions (lowercase, confusingly) are reusable pieces you can drop into your steps. GitHub has a marketplace full of them, and you can write your own. The whole thing runs on virtual machines GitHub calls "runners." Ubuntu 22.04 is what I use most of the time. Windows and macOS are available too, which matters if you're building cross-platform desktop software or doing iOS builds. Your First Workflow Before getting into the full ATS pipeline, here's the simplest useful version of a workflow: run your tests on every push and pull request. This is where I'd tell anyone to start, and it applies regardless of stack. For the React side of the ATS, it looks like this: name: Frontend CI on: push: branches: [main, develop] pull_request: branches: [main] jobs: test: runs-on: ubuntu-22.04 steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - name: Run tests run: npm test -- --watchAll=false The --watchAll=false flag matters if you're using Create React App or Vite with Jest, otherwise the test runner sits waiting for input and the job hangs forever. Ask me how I know. That caching step ( cache: 'npm' ) cut our install time from about 90 seconds to roughly 15 on warm runs. Small thing, but it adds up when you're triggering pipelines twenty times a day. The YAML Structure in More Detail I'll be honest, the YAML schema for GitHub Actions is a little dense when you first look at the docs. Lots of optional fields, nesting that gets confusing fast. Here's how I think about it: Workflow ├── name (just a label) ├── on (what triggers this) └── jobs └── [job-id] ├── runs-on (which OS/runner) ├── needs (dependencies on other jobs) ├── env (environment variables) └── steps ├── uses (run a pre-built action) └── run (run a shell command) The needs field is what I missed for a while. By default, all jobs in a workflow run in parallel. If you want your deploy job to wait for build to pass first, you have to be explicit about it. Miss that and you get the thing I got: a deploy job firing before the image even existed. Fun conversation with the team. The ATS Pipeline: Three Jobs, One Workflow The ATS pipeline has three distinct stages. I want to walk through each one separately because they do meaningfully different things, and collapsing them together is where pipelines get hard to debug. The stack is React on the frontend and Java 25 with Spring Boot 4 on the backend. We build both into a single Docker image (the Spring Boot app serves the React build as static assets), which keeps the deployment simple. One image, one container, one place to look when something breaks. Job 1: Build The build job compiles the React frontend, runs both test suites, packages everything into a Spring Boot fat JAR, and produces a Docker image. That image is the artifact everything downstream depends on. Nothing gets deployed unless this succeeds. Spring Boot 4 requires Java 25, so we use actions/setup-java@v4 with the Temurin distribution. This is the one detail that used to cause us pain on the old setup, where the Jenkins server had Java 17 installed and we kept forgetting to update it. name: ATS Pipeline on: push: branches: [main] jobs: build: runs-on: ubuntu-22.04 outputs: image-tag: ${{ steps.meta.outputs.version }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Java 25 uses: actions/setup-java@v4 with: java-version: '25' distribution: 'temurin' cache: 'maven' - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: frontend/package-lock.json - name: Install and build React frontend working-directory: frontend run: | npm ci npm test -- --watchAll=false npm run build - name: Copy React build into Spring Boot static resources run: | mkdir -p src/main/resources/static cp -r frontend/build/* src/main/resources/static/ - name: Run Spring Boot tests run: mvn test - name: Package Spring Boot application run: mvn package -DskipTests - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Extract image metadata id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/my-org/ats tags: | type=sha,prefix=,format=short - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max And the Dockerfile that goes with this is worth showing, because the Java 25 base image detail matters: FROM eclipse-temurin:25-jre-alpine WORKDIR /app COPY target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] We use the JRE variant rather than the JDK to keep the image smaller. The full JDK image for Temurin 25 is around 600MB; the JRE Alpine variant is closer to 180MB. Not a huge deal for a deploy, but it does affect pull times on the server side. A few other things worth calling out. The cache: 'maven' on the Java setup step caches your .m2 repository between runs, which cuts Maven dependency download time significantly on warm runs. The docker/metadata-action generates an image tag from the short Git SHA, so every image is traceable to an exact commit. And the cache-from: type=gha lines tell Buildx to use GitHub Actions' own layer cache, which cut our image build time from about four minutes to under ninety seconds. The outputs block at the top of the job is how we pass the image tag downstream to the deploy job. Without it, the next job has no idea what image was ju...