Master CI/CD with GitHub Actions for your web development projects. Learn how to build, test, and deploy scalable applications efficiently through automation.
If you're building web applications, you know that getting code from your local machine to production reliably and quickly is a huge challenge. That's where Continuous Integration and Continuous Deployment (CI/CD) come in. And lately, I've been really impressed with how powerful and flexible GitHub Actions can be for setting up these pipelines. It's not just for simple scripts anymore; we're talking about full-blown automation for complex, scalable web applications.
In this post, I want to walk you through how I approach building CI/CD pipelines with GitHub Actions, focusing on what it takes to make them work for applications that need to scale. We'll cover the core concepts, essential tools, and some practical examples.
Before diving in, let's quickly touch on why GitHub Actions has become my go-to for CI/CD.
A typical CI/CD pipeline for a web application involves several stages:
Let's start with a simple CI pipeline. Imagine we have a Node.js web application. Our goal here is to automatically build and test the application whenever code is pushed to a feature branch or a pull request is opened.
Here's a basic .github/workflows/ci.yml file:
YAMLname: Node.js CI on: push: branches: [ main, develop ] pull_request: branches: [ main, develop ] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [16.x, 18.x, 20.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - name: Checkout code uses: actions/checkout@v4 # Always good to use the latest version - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' # Cache npm dependencies for faster builds - name: Install dependencies run: npm ci # Use 'npm ci' for cleaner, faster installs in CI - name: Run linters run: npm run lint - name: Run tests run: npm test
Explanation:
name: The name of your workflow.on: Defines when this workflow runs. Here, it's on push or pull_request events to the main or develop branches.jobs: A workflow can have multiple jobs. Here, we have one job named build.runs-on: Specifies the type of runner to use. ubuntu-latest is a common choice.strategy.matrix: This is super handy. It allows you to run the same job with different configurations. Here, we're testing our app against three different Node.js versions (16.x, 18.x, 20.x).steps: A sequence of tasks to run within a job.
actions/checkout@v4: This action checks out your repository code so the workflow can access it. Using @v4 ensures you're on a recent, stable version.actions/setup-node@v4: Sets up the specified Node.js version. The cache: 'npm' option is crucial for performance; it caches your node_modules directory, drastically speeding up subsequent runs.npm ci: This command installs dependencies exactly as specified in package-lock.json. It's generally preferred over npm install in CI environments because it's faster and more reliable.npm run lint and npm test: These steps execute your defined linting and testing scripts from package.json. If either of these fails, the job fails, preventing bad code from progressing.Now, let's extend this to a CD pipeline. We want to deploy our application to a staging environment whenever we merge changes into the develop branch. For this example, let's assume we're deploying to a cloud platform like AWS Elastic Beanstalk, or perhaps a container registry like Docker Hub and then to Kubernetes.
We'll need a separate job for deployment, and it should only run after the build job on the develop branch succeeds.
YAMLname: Node.js CI/CD on: push: branches: [ main, develop ] pull_request: branches: [ main, develop ] jobs: build_and_test: runs-on: ubuntu-latest strategy: matrix: node-version: [16.x, 18.x, 20.x] steps: - name: Checkout code uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run linters run: npm run lint - name: Run tests run: npm test - name: Build application run: npm run build # Assuming 'npm run build' creates production assets # Optional: Build and push Docker image # - name: Log in to Docker Hub # uses: docker/login-action@v2 # with: # username: ${{ secrets.DOCKERHUB_USERNAME }} # password: ${{ secrets.DOCKERHUB_TOKEN }} # - name: Build and push Docker image # uses: docker/build-push-action@v4 # with: # context: . # push: true # tags: your-dockerhub-username/your-app:${{ github.sha }} # Tag with commit SHA deploy_staging: runs-on: ubuntu-latest needs: build_and_test # This job depends on build_and_test completing successfully if: github.ref == 'refs/heads/develop' && needs.build_and_test.result == 'success' # Only run on develop branch pushes that pass CI steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '18' # Use a consistent version for deployment - name: Install dependencies run: npm ci - name: Build application run: npm run build # --- Deployment Steps --- # Example for AWS Elastic Beanstalk (requires AWS CLI and EB CLI configured) # - name: Configure AWS Credentials # uses: aws-actions/configure-aws-credentials@v1 # with: # aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} # aws-access-key-secret: ${{ secrets.AWS_SECRET_ACCESS_KEY }} # aws-region: us-east-1 # - name: Deploy to Elastic Beanstalk # run: | # EB_APP_NAME="your-eb-app-name" # EB_ENV_NAME="your-staging-env-name" # # Create application version and deploy # eb deploy $EB_ENV_NAME --version-label $GITHUB_SHA --message "Deployment from GitHub Actions commit ${{ github.sha }}" # Example for deploying a Docker image to Kubernetes (using kubectl) # This is a simplified example; real-world K8s deployments often use Helm or other tools. #