Escaping the Fear of Deploying Manually: Building a CI/CD Pipeline with GitHub Actions
"Oh no, I forgot to run the tests..."
Just the other day, I felt that sudden wave of panic right before a release. Everything had worked perfectly in my local environment, and the build seemed successful. However, once the site went live, the logs were immediately flooded with unexpected runtime errors. I ended up caught in the worst possible loop: frantically fixing bugs and redeploying over and over again.
These kinds of "oops" moments become increasingly critical as a project grows in scale. Even for solo developers, lacking a system to catch human error can be mentally taxing. This realization led me to build a CI/CD pipeline using GitHub Actions to automate everything from testing and building to deployment.
To be honest, I initially struggled with the "how-to" of writing these workflows. A single misplaced indentation in a YAML file could trigger an error, and it took some time and research to figure out how to manage environment variables (Secrets) securely for deployment.
My goal was to create a configuration that was both simple and robust. Specifically, I designed a flow where pushing to the `main` branch triggers "Lint checks," "Unit tests," and "Build" processes; only if all of these pass does the system automatically proceed to deployment.
Below is the implementation example for my workflow (`.github/workflows/ci-cd.yml`).
name: CI/CD Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Lint
run: npm run lint
- name: Run Tests
run: npm test
- name: Build project
run: npm run build
deploy:
needs: test-and-build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy to Server
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
echo "Deploying with token..."
# Insert actual deployment commands here (e.g., AWS S3 sync, SSH commands, etc.)
The most significant change after implementing this setup was the sense of "peace of mind" I felt after every push. If a test fails or a type error occurs during the build, I receive an immediate notification on GitHub. In other words, I can now catch "broken code" before it ever reaches production.
The `needs: test-and-build` declaration is particularly crucial here. By including this, I ensure that the subsequent deployment job will not trigger unless the testing and building phases are successful. Without this safeguard, there is a risk that the system might attempt to deploy even if the tests fail.
Additionally, I moved all sensitive information—such as API keys and SSH keys—into the `Secrets` section of the GitHub repository settings. Hardcoding these into the workflow file is out of the question; establishing a system to inject them securely as environment variables was another major hurdle in building this CI/CD pipeline.
Admittedly, there were moments where I wondered if I was over-engineering it. However, once you experience the benefits of automation, returning to manual processes feels like a step backward. By letting tools handle the repetitive tasks, engineers can focus on what truly matters: developing core logic.
As my projects become more complex, I plan to add further steps such as optimizing caching strategies and creating container images. I look forward to gradually evolving this into an even more robust pipeline.