DevOps

DevOps for Developers: CI/CD with GitHub Actions, Firebase Hosting & Docker

DevOps for Developers CICD with GitHub Actions, Firebase Hosting & Docker

Introduction

If you’re a developer looking to automate your deployment workflow, this guide will help you set up a powerful CI/CD pipeline using GitHub Actions, Firebase Hosting, and Docker. Manual deployments are error-prone, time-consuming, and don’t scale. A well-designed CI/CD pipeline lets you ship faster, catch bugs earlier, and maintain consistent environments from development to production.

You’ll learn how to streamline your development process, reduce human error, and ship faster—without becoming a DevOps expert. By the end of this guide, you’ll have a complete pipeline that automatically builds, tests, containerizes, and deploys your application on every push to main.

What You’ll Learn

  • Setting up GitHub Actions for CI/CD with proper job organization
  • Building and pushing Docker images to registries
  • Deploying to Firebase Hosting automatically with preview channels
  • Implementing environment-specific deployments (staging, production)
  • Securing secrets and optimizing build caching

Why CI/CD Matters for Developers

CI/CD (Continuous Integration and Continuous Deployment) is essential for modern software teams. It ensures that every change is tested, built, and deployed automatically:

  • Faster feedback loops: Know within minutes if your code broke something
  • Reduced risk: Small, frequent deployments are safer than big releases
  • Consistency: Same build process every time, eliminating “works on my machine”
  • Developer focus: Spend time writing code, not manually deploying

Prerequisites

To follow this guide, you’ll need:

  • A GitHub repository with your application code
  • A Firebase project (with Hosting enabled)
  • Docker installed locally for testing
  • firebase-tools installed globally (npm install -g firebase-tools)
  • Firebase CLI authenticated with your project

Step 1: Set Up Firebase Hosting

First, initialize Firebase Hosting in your project:

# Install Firebase CLI
npm install -g firebase-tools

# Login to Firebase
firebase login

# Initialize hosting
firebase init hosting

# Select options:
# - Choose your Firebase project
# - Set public directory (build/ for React, dist/ for Vue/Vite)
# - Configure as single-page app: Yes
# - Set up automatic builds with GitHub: No (we'll do this manually)

Your firebase.json should look like:

{
  "hosting": {
    "public": "build",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ],
    "headers": [
      {
        "source": "**/*.@(js|css)",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "max-age=31536000"
          }
        ]
      },
      {
        "source": "**/*.@(jpg|jpeg|gif|png|svg|webp|ico)",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "max-age=31536000"
          }
        ]
      }
    ]
  }
}

Step 2: Create GitHub Actions Workflow

Create a comprehensive workflow at .github/workflows/deploy.yml:

# .github/workflows/deploy.yml
name: CI/CD Pipeline

on:
  push:
    branches:
      - main
      - develop
  pull_request:
    branches:
      - main
      - develop

# Cancel in-progress runs for the same branch
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

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

jobs:
  # Job 1: Lint and Type Check
  lint:
    name: Lint & Type Check
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run ESLint
        run: npm run lint
      
      - name: Run TypeScript check
        run: npm run type-check
  
  # Job 2: Run Tests
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    needs: lint
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run unit tests
        run: npm test -- --coverage
      
      - name: Upload coverage report
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/lcov.info
          fail_ci_if_error: false
  
  # Job 3: Build Application
  build:
    name: Build Application
    runs-on: ubuntu-latest
    needs: [lint, test]
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build application
        run: npm run build
        env:
          # Inject environment variables for the build
          REACT_APP_API_URL: ${{ secrets.API_URL }}
          REACT_APP_FIREBASE_CONFIG: ${{ secrets.FIREBASE_CONFIG }}
      
      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build
          path: build/
          retention-days: 7
  
  # Job 4: Build Docker Image
  docker:
    name: Build & Push Docker Image
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    
    permissions:
      contents: read
      packages: write
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build
          path: build/
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      
      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}
      
      - 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
  
  # Job 5: Deploy Preview (for PRs)
  deploy-preview:
    name: Deploy Preview
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'pull_request'
    
    permissions:
      contents: read
      pull-requests: write
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build
          path: build/
      
      - name: Deploy to Firebase Hosting Preview
        uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          repoToken: ${{ secrets.GITHUB_TOKEN }}
          firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
          projectId: ${{ secrets.FIREBASE_PROJECT_ID }}
          expires: 7d
        env:
          FIREBASE_CLI_EXPERIMENTS: webframeworks
  
  # Job 6: Deploy to Staging
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
    environment:
      name: staging
      url: https://staging.example.com
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build
          path: build/
      
      - name: Deploy to Firebase Hosting (Staging)
        uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          repoToken: ${{ secrets.GITHUB_TOKEN }}
          firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_STAGING }}
          projectId: ${{ secrets.FIREBASE_PROJECT_ID_STAGING }}
          channelId: staging
  
  # Job 7: Deploy to Production
  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: [build, docker]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment:
      name: production
      url: https://example.com
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build
          path: build/
      
      - name: Deploy to Firebase Hosting (Production)
        uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          repoToken: ${{ secrets.GITHUB_TOKEN }}
          firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
          projectId: ${{ secrets.FIREBASE_PROJECT_ID }}
          channelId: live

Step 3: Production-Ready Dockerfile

Create an optimized multi-stage Dockerfile:

# Dockerfile

# Stage 1: Build
FROM node:20-alpine AS builder

WORKDIR /app

# Copy package files first for better caching
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production=false

# Copy source code
COPY . .

# Build the application
RUN npm run build

# Stage 2: Production
FROM nginx:alpine AS production

# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf

# Copy built assets from builder stage
COPY --from=builder /app/build /usr/share/nginx/html

# Add healthcheck
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:80/health || exit 1

# Expose port
EXPOSE 80

# Start nginx
CMD ["nginx", "-g", "daemon off;"]

Create the nginx configuration:

# nginx.conf
events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml;

    server {
        listen 80;
        server_name localhost;
        root /usr/share/nginx/html;
        index index.html;

        # Health check endpoint
        location /health {
            access_log off;
            return 200 "healthy\n";
            add_header Content-Type text/plain;
        }

        # SPA routing - serve index.html for all routes
        location / {
            try_files $uri $uri/ /index.html;
        }

        # Cache static assets
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }

        # Security headers
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-XSS-Protection "1; mode=block" always;
    }
}

Step 4: Configure Secrets

Set up the required secrets in your GitHub repository:

# Go to: Repository Settings → Secrets and variables → Actions

# Required secrets:

# 1. FIREBASE_SERVICE_ACCOUNT
# Generate this from Firebase Console:
# - Go to Project Settings → Service accounts
# - Click "Generate new private key"
# - Copy the entire JSON content as the secret value

# 2. FIREBASE_PROJECT_ID
# Your Firebase project ID (found in project settings)

# 3. API_URL (optional)
# Your API endpoint for environment variables

# 4. CODECOV_TOKEN (optional)
# Get from codecov.io if using coverage reports

For separate staging and production environments:

# Create two Firebase projects:
# - myapp-staging
# - myapp-production

# Add separate secrets:
# - FIREBASE_SERVICE_ACCOUNT_STAGING
# - FIREBASE_PROJECT_ID_STAGING
# - FIREBASE_SERVICE_ACCOUNT (for production)
# - FIREBASE_PROJECT_ID (for production)

Step 5: Environment Protection Rules

Configure production environment protection:

# Go to: Repository Settings → Environments

# Create "production" environment:
# - Add required reviewers (team leads who must approve)
# - Add deployment branches rule: main only
# - Optional: Add wait timer (e.g., 5 minutes delay)

# Create "staging" environment:
# - Add deployment branches rule: develop only
# - No reviewer required for faster iteration

Step 6: Optimize with Caching

# Enhanced caching for faster builds

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      # Cache node_modules
      - name: Cache node modules
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-
      
      # Cache build output (for incremental builds)
      - name: Cache build
        uses: actions/cache@v4
        with:
          path: |
            build/.cache
            .next/cache
          key: ${{ runner.os }}-build-${{ hashFiles('src/**') }}
          restore-keys: |
            ${{ runner.os }}-build-
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - run: npm ci
      - run: npm run build

Real-World Use Cases

  • Web Apps: Deploy React, Angular, Vue, or Next.js apps to Firebase Hosting with automatic preview URLs for PRs
  • APIs: Containerize Node/Express, Python/FastAPI, or Go services and deploy to Cloud Run or Kubernetes
  • Microservices: Manage multiple services with matrix builds and independent deployment schedules
  • Monorepos: Use path filters to only build/deploy changed packages

Common Mistakes to Avoid

Hardcoding secrets in workflows: Never put API keys or credentials directly in YAML files. Always use GitHub Secrets.

Not using concurrency limits: Without the concurrency setting, multiple deployments can race and cause issues.

Skipping the build step for Docker: Building inside Docker is slower. Build once, copy artifacts to the container.

Not testing before deploying: Always run lint and tests as prerequisite jobs before deployment.

Missing environment protection: Production deployments should require manual approval for safety.

Ignoring caching: CI runs can be 2-3x faster with proper npm and build caching.

Conclusion

By combining GitHub Actions, Firebase Hosting, and Docker, you create a fast, flexible, and scalable CI/CD pipeline tailored for developers. The workflow we built includes linting, testing, building, Docker containerization, preview deployments for PRs, and separate staging/production environments with proper protection rules.

Whether you’re shipping a web app or backend service, automation gives you the power to move faster and with confidence. Start with the basic workflow and gradually add more jobs as your needs grow.

For more on containerization, check out our guide on deploying Spring Boot apps to Docker and Kubernetes. For official documentation, see the GitHub Actions documentation.

1 Comment

Leave a Comment