A few months ago, I dove into DevOps, expecting it to be an expensive journey requiring costly tools and infrastructure. But I discovered you can build professional-grade pipelines using entirely free resources.
If DevOps feels out of reach because you’re also concerned about the cost, don’t worry. I’ll guide you step-by-step through creating a production-ready pipeline without spending a dime. Let’s get started!
Table of Contents
🛠 Prerequisites
Basic Git knowledge: Cloning repos, creating branches, committing code, and creating PRs
Familiarity with command line: For Docker, Terraform, and Kubernetes
Basic understanding of CI/CD: Continuous integration/delivery concepts and pipelines
Accounts needed:
GitHub account
At least one cloud provider: AWS Free Tier (recommended), Oracle Cloud Free Tier, or Google Cloud/Azure with free credits
Terraform Cloud (free tier) for infrastructure state management
Grafana Cloud (free tier) for monitoring
UptimeRobot (free tier) for external availability checks
Tools to Install Locally
Tool | Purpose | Installation Link |
Git | Version control | Install Git |
Docker | Containerization | Install Docker |
Node.js & npm | Sample app & builds | Install Node.js |
Terraform | Infrastructure as Code | Install Terraform |
kubectl | Kubernetes CLI | Install kubectl |
k3d | Lightweight Kubernetes | Install k3d |
Trivy | Container security scanning | Install Trivy |
OWASP ZAP | Web security scanning | Install ZAP |
Optional but Helpful:
VS Code or any good code editor
Postman for testing APIs
Understanding of YAML and Dockerfiles
Introduction
When people hear “DevOps,” they often picture complex enterprise systems powered by pricey tools and premium cloud services. But the truth is, you don’t actually need a massive budget to build a solid, professional-grade DevOps pipeline. The foundations of good DevOps – automation, consistency, security, and visibility – can be built entirely with free tools.
In this guide, you will learn how to build a production-ready DevOps pipeline using zero-cost resources. We will use a simple CRUD (Create, Read, Update, Delete) app with frontend, backend API, and database as our example project to demonstrate every step of the process.
How to Set Up Your Source Control and Project Structure
1. Create a Well-Structured Repository
A clean repo is the foundation of your pipeline. We will set up:
Separate folders for
frontend
,backend
, andinfrastructure
A
.github
folder to hold workflow configurationsClear naming conventions and a well-written
README.md
🛠 Tip: Use semantic commit messages and consider adopting Conventional Commits for clarity in versioning and changelogs.
2. Set Up Branch Protection Without Paid Features
While GitHub’s more advanced rules require Pro, you can still:
Require pull requests before merging
Enable status checks to prevent broken code from landing in
main
Enforce linear history for cleaner version control
💡 This makes your project safer and more collaborative, without needing GitHub Enterprise.
3. Implement PR Templates and Automated Checks
Make your reviews smoother:
Add a
PULL_REQUEST_TEMPLATE.md
to guide contributorsUse GitHub Actions (which we’ll set up in the next part) for linting, tests, and formatting checks
✨ These tiny improvements add polish and professionalism.
4. Configure GitHub Issue Templates and Project Boards
Even solo developers benefit from issue tracking:
Add issue templates for bugs and features
Use GitHub Projects to manage work with a Kanban board, all free and native to GitHub
📌 Bonus: This setup lays the groundwork for GitOps practices later on.
5. Advanced Technique: Set Up Custom Validation Scripts as Pre-Commit Hooks
Before code ever hits GitHub, you can catch issues locally with Git hooks. Using a tool like Husky or pre-commit, you can:
Lint code before it’s committed
Run tests or formatters automatically
Prevent secrets from being accidentally committed
<span class="hljs-comment">// Initialize Husky and install needed dependencies</span>
<span class="hljs-comment">// Then add a pre-commit hook that runs tests before allowing the commit</span>
npx husky-init && npm install
npx husky add .husky/pre-commit <span class="hljs-string">"npm test"</span>
6. Sample CRUD App Setup:
Our CRUD app manages users (create, read, update, delete). Below is the minimal code with comments to explain each part:
Backend (backend/)
:
<span class="hljs-comment">// backend/package.json</span>
{
<span class="hljs-attr">"name"</span>: <span class="hljs-string">"crud-backend"</span>, <span class="hljs-comment">// Name of the backend project</span>
<span class="hljs-attr">"version"</span>: <span class="hljs-string">"1.0.0"</span>, <span class="hljs-comment">// Version for tracking changes</span>
<span class="hljs-attr">"scripts"</span>: {
<span class="hljs-attr">"start"</span>: <span class="hljs-string">"node index.js"</span>, <span class="hljs-comment">// Runs the server</span>
<span class="hljs-attr">"test"</span>: <span class="hljs-string">"echo 'Add tests here'"</span>, <span class="hljs-comment">// Placeholder for tests (update with Jest later)</span>
<span class="hljs-attr">"lint"</span>: <span class="hljs-string">"eslint ."</span> <span class="hljs-comment">// Checks code style with ESLint</span>
},
<span class="hljs-attr">"dependencies"</span>: {
<span class="hljs-attr">"express"</span>: <span class="hljs-string">"^4.17.1"</span>, <span class="hljs-comment">// Web framework for API endpoints</span>
<span class="hljs-attr">"pg"</span>: <span class="hljs-string">"^8.7.3"</span> <span class="hljs-comment">// PostgreSQL client to connect to the database</span>
},
<span class="hljs-attr">"devDependencies"</span>: {
<span class="hljs-attr">"eslint"</span>: <span class="hljs-string">"^8.0.0"</span> <span class="hljs-comment">// Linting tool for code quality</span>
}
}
<span class="hljs-comment">// backend/index.js</span>
<span class="hljs-keyword">const</span> express = <span class="hljs-built_in">require</span>(<span class="hljs-string">'express'</span>); <span class="hljs-comment">// Import Express for building the API</span>
<span class="hljs-keyword">const</span> { Pool } = <span class="hljs-built_in">require</span>(<span class="hljs-string">'pg'</span>); <span class="hljs-comment">// Import PostgreSQL client</span>
<span class="hljs-keyword">const</span> app = express(); <span class="hljs-comment">// Create an Express app</span>
app.use(express.json()); <span class="hljs-comment">// Parse JSON request bodies</span>
<span class="hljs-comment">// Connect to PostgreSQL using DATABASE_URL from environment variables</span>
<span class="hljs-keyword">const</span> pool = <span class="hljs-keyword">new</span> Pool({ <span class="hljs-attr">connectionString</span>: process.env.DATABASE_URL });
<span class="hljs-comment">// Health check endpoint for Kubernetes probes and monitoring</span>
app.get(<span class="hljs-string">'/healthz'</span>, <span class="hljs-function">(<span class="hljs-params">req, res</span>) =></span> res.json({ <span class="hljs-attr">status</span>: <span class="hljs-string">'ok'</span> }));
<span class="hljs-comment">// Get all users from the database</span>
app.get(<span class="hljs-string">'/users'</span>, <span class="hljs-keyword">async</span> (req, res) => {
<span class="hljs-keyword">const</span> { rows } = <span class="hljs-keyword">await</span> pool.query(<span class="hljs-string">'SELECT * FROM users'</span>); <span class="hljs-comment">// Query the users table</span>
res.json(rows); <span class="hljs-comment">// Send users as JSON</span>
});
<span class="hljs-comment">// Add a new user to the database</span>
app.post(<span class="hljs-string">'/users'</span>, <span class="hljs-keyword">async</span> (req, res) => {
<span class="hljs-keyword">const</span> { name } = req.body; <span class="hljs-comment">// Get name from request body</span>
<span class="hljs-comment">// Insert user and return the new record</span>
<span class="hljs-keyword">const</span> { rows } = <span class="hljs-keyword">await</span> pool.query(<span class="hljs-string">'INSERT INTO users(name) VALUES($1) RETURNING *'</span>, [name]);
res.json(rows[<span class="hljs-number">0</span>]); <span class="hljs-comment">// Send the new user as JSON</span>
});
<span class="hljs-comment">// Start the server on port 3000</span>
app.listen(<span class="hljs-number">3000</span>, <span class="hljs-function">() =></span> <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Backend running on port 3000'</span>));
Frontend (frontend/)
:
<span class="hljs-comment">// frontend/package.json</span>
{
<span class="hljs-string">"name"</span>: <span class="hljs-string">"crud-frontend"</span>, <span class="hljs-comment">// Name of the frontend project</span>
<span class="hljs-string">"version"</span>: <span class="hljs-string">"1.0.0"</span>, <span class="hljs-comment">// Version for tracking changes</span>
<span class="hljs-string">"scripts"</span>: {
<span class="hljs-string">"start"</span>: <span class="hljs-string">"react-scripts start"</span>, <span class="hljs-comment">// Runs the dev server</span>
<span class="hljs-string">"build"</span>: <span class="hljs-string">"react-scripts build"</span>, <span class="hljs-comment">// Builds for production</span>
<span class="hljs-string">"test"</span>: <span class="hljs-string">"react-scripts test"</span>, <span class="hljs-comment">// Runs tests (placeholder for Jest)</span>
<span class="hljs-string">"lint"</span>: <span class="hljs-string">"eslint ."</span> <span class="hljs-comment">// Checks code style with ESLint</span>
},
<span class="hljs-string">"dependencies"</span>: {
<span class="hljs-string">"react"</span>: <span class="hljs-string">"^17.0.2"</span>, <span class="hljs-comment">// Core React library</span>
<span class="hljs-string">"react-dom"</span>: <span class="hljs-string">"^17.0.2"</span>, <span class="hljs-comment">// Renders React to the DOM</span>
<span class="hljs-string">"react-scripts"</span>: <span class="hljs-string">"^4.0.3"</span>, <span class="hljs-comment">// Scripts for React development</span>
<span class="hljs-string">"axios"</span>: <span class="hljs-string">"^0.24.0"</span> <span class="hljs-comment">// HTTP client for API calls</span>
},
<span class="hljs-string">"devDependencies"</span>: {
<span class="hljs-string">"eslint"</span>: <span class="hljs-string">"^8.0.0"</span> <span class="hljs-comment">// Linting tool for code quality</span>
}
}
<span class="hljs-comment">// frontend/src/App.js</span>
<span class="hljs-keyword">import</span> React, { useState, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>; <span class="hljs-comment">// Import React and hooks</span>
<span class="hljs-keyword">import</span> axios <span class="hljs-keyword">from</span> <span class="hljs-string">'axios'</span>; <span class="hljs-comment">// Import Axios for API requests</span>
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">App</span>(<span class="hljs-params"></span>) </span>{
<span class="hljs-comment">// State for storing users fetched from the backend</span>
<span class="hljs-keyword">const</span> [users, setUsers] = useState([]);
<span class="hljs-comment">// State for the input field to add a new user</span>
<span class="hljs-keyword">const</span> [name, setName] = useState(<span class="hljs-string">''</span>);
<span class="hljs-comment">// Fetch users when the component mounts</span>
useEffect(<span class="hljs-function">() =></span> {
axios.get(<span class="hljs-string">'http://localhost:3000/users'</span>).then(<span class="hljs-function"><span class="hljs-params">res</span> =></span> setUsers(res.data));
}, []); <span class="hljs-comment">// Empty array means run once on mount</span>
<span class="hljs-comment">// Add a new user via the API</span>
<span class="hljs-keyword">const</span> addUser = <span class="hljs-keyword">async</span> () => {
<span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> axios.post(<span class="hljs-string">'http://localhost:3000/users'</span>, { name }); <span class="hljs-comment">// Post new user</span>
setUsers([...users, res.data]); <span class="hljs-comment">// Update users list</span>
setName(<span class="hljs-string">''</span>); <span class="hljs-comment">// Clear input field</span>
};
<span class="hljs-keyword">return</span> (
<span class="xml"><span class="hljs-tag"><<span class="hljs-name">div</span>></span>
<span class="hljs-tag"><<span class="hljs-name">h1</span>></span>Users<span class="hljs-tag"></<span class="hljs-name">h1</span>></span>
{/* Input for new user name */}
<span class="hljs-tag"><<span class="hljs-name">input</span> <span class="hljs-attr">value</span>=<span class="hljs-string">{name}</span> <span class="hljs-attr">onChange</span>=<span class="hljs-string">{e</span> =></span> setName(e.target.value)} />
{/* Button to add user */}
<span class="hljs-tag"><<span class="hljs-name">button</span> <span class="hljs-attr">onClick</span>=<span class="hljs-string">{addUser}</span>></span>Add User<span class="hljs-tag"></<span class="hljs-name">button</span>></span>
{/* List all users */}
<span class="hljs-tag"><<span class="hljs-name">ul</span>></span>{users.map(user => <span class="hljs-tag"><<span class="hljs-name">li</span> <span class="hljs-attr">key</span>=<span class="hljs-string">{user.id}</span>></span>{user.name}<span class="hljs-tag"></<span class="hljs-name">li</span>></span>)}<span class="hljs-tag"></<span class="hljs-name">ul</span>></span>
<span class="hljs-tag"></<span class="hljs-name">div</span>></span></span>
);
}
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> App; <span class="hljs-comment">// Export the component</span>
Database Setup:
<span class="hljs-comment">-- infra/db.sql</span>
<span class="hljs-comment">-- Create a table to store users</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> users (
id <span class="hljs-type">SERIAL</span> <span class="hljs-keyword">PRIMARY KEY</span>, <span class="hljs-comment">-- Auto-incrementing ID</span>
<span class="hljs-type">name</span> <span class="hljs-type">VARCHAR</span>(<span class="hljs-number">100</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">NULL</span> <span class="hljs-comment">-- User name, required</span>
);
crud-app/
├── backend/
│ ├── package.json
│ └── index.js
├── frontend/
│ ├── package.json
│ └── src/App.js
├── infra/
│ └── db.sql
├── .github/
│ └── workflows/
└── README.md
This app provides a /users
endpoint (GET/POST) and a frontend to list/add users, stored in PostgreSQL. The /healthz
endpoint supports monitoring. Save this code in your repo to follow the pipeline steps.
How to Build Your CI Pipeline with GitHub Actions
1. Set Up Your First GitHub Actions Workflow
First, let’s create a basic workflow that automatically builds, tests, and lints your app every time you push code or open a pull request. This ensures your app stays healthy and any issues are caught early.
Create a file at .github/workflows/ci.yml
and add the following:
<span class="hljs-comment"># CI workflow to build, test, and lint the CRUD app on push or pull request</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">CI</span> <span class="hljs-string">Pipeline</span>
<span class="hljs-attr">on:</span>
<span class="hljs-attr">push:</span>
<span class="hljs-attr">branches:</span> [<span class="hljs-string">main</span>] <span class="hljs-comment"># Trigger on pushes to main branch</span>
<span class="hljs-attr">pull_request:</span>
<span class="hljs-attr">branches:</span> [<span class="hljs-string">main</span>] <span class="hljs-comment"># Trigger on PRs to main branch</span>
<span class="hljs-attr">jobs:</span>
<span class="hljs-attr">build:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span> <span class="hljs-comment"># Use GitHub's free Linux runner</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span> <span class="hljs-comment"># Check out the repository code</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Set</span> <span class="hljs-string">up</span> <span class="hljs-string">Node.js</span> <span class="hljs-comment"># Install Node.js environment</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-node@v3</span>
<span class="hljs-attr">with:</span>
<span class="hljs-attr">node-version:</span> <span class="hljs-string">'18'</span> <span class="hljs-comment"># Use Node.js 18 for consistency</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Cache</span> <span class="hljs-string">dependencies</span> <span class="hljs-comment"># Cache node_modules to speed up builds</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">actions/cache@v3</span>
<span class="hljs-attr">with:</span>
<span class="hljs-attr">path:</span> <span class="hljs-string">~/.npm</span> <span class="hljs-comment"># Cache npm’s global cache</span>
<span class="hljs-attr">key:</span> <span class="hljs-string">${{</span> <span class="hljs-string">runner.os</span> <span class="hljs-string">}}-node-${{</span> <span class="hljs-string">hashFiles('**/package-lock.json')</span> <span class="hljs-string">}}</span> <span class="hljs-comment"># Key based on OS and package-lock.json</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">ci</span> <span class="hljs-comment"># Install dependencies reliably using package-lock.json</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">test</span> <span class="hljs-comment"># Run tests defined in package.json</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">run</span> <span class="hljs-string">lint</span> <span class="hljs-comment"># Run ESLint to ensure code quality</span>
This workflow automatically runs on every push and pull request to the main
branch. It installs dependencies, runs tests, and performs code linting, with dependency caching to make builds faster over time.
Common Issues and Fixes:
“Secret not found”: Ensure
AWS_ACCESS_KEY_ID
is in repository secrets (Settings → Secrets).Tests fail: Check
test/users.test.js
for database connectivity.
Understanding GitHub Actions’ Free Tier Limits
Before building more workflows, it is important to know what GitHub offers for free.
If you are working on private repositories, you get 2,000 free minutes per month. For public repositories, you get unlimited minutes.
To avoid hitting limits quickly:
Cache your dependencies to cut down install times.
Only trigger workflows on meaningful branches (like
main
orrelease
).Skip unnecessary steps when you can.
2. Creating a Multi-Stage Build Pipeline
As your app grows, it is better to split your CI pipeline into clear stages like install, test, and lint. This structure makes workflows easier to maintain and speeds things up, because some jobs can run in parallel.
Here’s how you can split the work into multiple jobs for better clarity:
<span class="hljs-attr">jobs:</span>
<span class="hljs-attr">install:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">ci</span> <span class="hljs-comment"># Clean install of dependencies</span>
<span class="hljs-attr">test:</span>
<span class="hljs-attr">needs:</span> <span class="hljs-string">install</span> <span class="hljs-comment"># This job depends on the install job finishing</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">test</span> <span class="hljs-comment"># Run test suite</span>
<span class="hljs-attr">lint:</span>
<span class="hljs-attr">needs:</span> <span class="hljs-string">install</span> <span class="hljs-comment"># This job also depends on install but runs in parallel with test</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">run</span> <span class="hljs-string">lint</span> <span class="hljs-comment"># Run linting checks</span>
By breaking the pipeline into stages, you can quickly spot which step fails, and your test and lint jobs can run at the same time after dependencies are installed.
3. Implement Matrix Builds for Cross-Environment Testing
When you want your app to work across different Node.js versions or databases, matrix builds are your best bet. They let you test across multiple environments in parallel, without duplicating code.
Here’s how you can set up a matrix strategy, to test across multiple environments simultaneously:
<span class="hljs-attr">jobs:</span>
<span class="hljs-attr">test:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">strategy:</span>
<span class="hljs-attr">matrix:</span>
<span class="hljs-attr">node-version:</span> [<span class="hljs-number">14.</span><span class="hljs-string">x</span>, <span class="hljs-number">16.</span><span class="hljs-string">x</span>, <span class="hljs-number">18.</span><span class="hljs-string">x</span>] <span class="hljs-comment"># Test on multiple Node versions</span>
<span class="hljs-attr">database:</span> [<span class="hljs-string">postgres</span>, <span class="hljs-string">mysql</span>] <span class="hljs-comment"># Test against different databases</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Use</span> <span class="hljs-string">Node.js</span> <span class="hljs-string">${{</span> <span class="hljs-string">matrix.node-version</span> <span class="hljs-string">}}</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-node@v3</span>
<span class="hljs-attr">with:</span>
<span class="hljs-attr">node-version:</span> <span class="hljs-string">${{</span> <span class="hljs-string">matrix.node-version</span> <span class="hljs-string">}}</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">install</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">test</span> <span class="hljs-comment"># This will run 6 different test combinations (3 Node versions × 2 databases)</span>
Matrix builds save time and help you catch environment-specific bugs early.
4. Optimize Workflow with Dependency Caching
Every second counts in CI. Dependency caching can help save minutes in your workflow by reusing previously installed packages instead of reinstalling them from scratch every time.
Here’s how to set up smart caching to speed up your builds:
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Cache</span> <span class="hljs-string">node</span> <span class="hljs-string">modules</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">actions/cache@v3</span>
<span class="hljs-attr">with:</span>
<span class="hljs-attr">path:</span> <span class="hljs-string">|</span> <span class="hljs-comment"># Cache both global npm cache and local node_modules</span>
<span class="hljs-string">~/.npm</span>
<span class="hljs-string">node_modules</span>
<span class="hljs-attr">key:</span> <span class="hljs-string">${{</span> <span class="hljs-string">runner.os</span> <span class="hljs-string">}}-node-${{</span> <span class="hljs-string">hashFiles('**/package-lock.json')</span> <span class="hljs-string">}}</span> <span class="hljs-comment"># Cache key based on OS and dependencies</span>
<span class="hljs-attr">restore-keys:</span> <span class="hljs-string">|</span> <span class="hljs-comment"># Fallback keys if exact match isn't found</span>
<span class="hljs-string">${{</span> <span class="hljs-string">runner.os</span> <span class="hljs-string">}}-node-</span>
This cache setup checks if your dependencies have changed. If not, it restores the cache, making builds significantly faster.
How to Optimize Docker Builds for CI
When you’re building Docker images in CI, build time can quickly become a bottleneck. Especially if your images are large. Optimizing your Docker builds makes your pipelines much faster, saves bandwidth, and produces smaller, more efficient images ready for deployment.
In this section, I’ll walk through creating a basic Dockerfile, using multi-stage builds, caching layers, and enabling BuildKit for even faster builds.
1. Create a Baseline Dockerfile
First, start with a simple Dockerfile that installs your app’s dependencies and runs it. This is what you’ll be optimizing later.
<span class="hljs-comment"># Simple Dockerfile for a Node.js application</span>
<span class="hljs-keyword">FROM</span> node:<span class="hljs-number">18</span>-alpine <span class="hljs-comment"># Use Alpine for a smaller base image</span>
<span class="hljs-keyword">WORKDIR</span><span class="bash"> /app <span class="hljs-comment"># Set working directory</span></span>
<span class="hljs-keyword">COPY</span><span class="bash"> . . <span class="hljs-comment"># Copy all files to container</span></span>
<span class="hljs-keyword">RUN</span><span class="bash"> npm ci <span class="hljs-comment"># Install dependencies (clean install)</span></span>
<span class="hljs-keyword">CMD</span><span class="bash"> [<span class="hljs-string">"npm"</span>, <span class="hljs-string">"start"</span>] <span class="hljs-comment"># Start the application</span></span>
Using an Alpine-based Node.js image helps keep your image small from the start.
2. Multi-Stage Docker Builds
Next, let’s separate the build process from the production image. Multi-stage builds let you compile or build your app in one stage and only copy over the final product to a clean, smaller image. This keeps production images lean:
<span class="hljs-comment"># Stage 1: Build the application</span>
<span class="hljs-keyword">FROM</span> node:<span class="hljs-number">18</span>-alpine AS builder
<span class="hljs-keyword">WORKDIR</span><span class="bash"> /app</span>
<span class="hljs-keyword">COPY</span><span class="bash"> package*.json ./ <span class="hljs-comment"># Copy package files first for better caching</span></span>
<span class="hljs-keyword">RUN</span><span class="bash"> npm ci <span class="hljs-comment"># Install all dependencies</span></span>
<span class="hljs-keyword">COPY</span><span class="bash"> . . <span class="hljs-comment"># Then copy source code</span></span>
<span class="hljs-keyword">RUN</span><span class="bash"> npm run build <span class="hljs-comment"># Build the application</span></span>
<span class="hljs-comment"># Stage 2: Production image with minimal footprint</span>
<span class="hljs-keyword">FROM</span> node:<span class="hljs-number">18</span>-alpine
<span class="hljs-keyword">WORKDIR</span><span class="bash"> /app</span>
<span class="hljs-comment"># Only copy built assets and production dependencies</span>
<span class="hljs-keyword">COPY</span><span class="bash"> --from=builder /app/dist ./dist</span>
<span class="hljs-keyword">COPY</span><span class="bash"> --from=builder /app/package*.json ./</span>
<span class="hljs-keyword">RUN</span><span class="bash"> npm ci --production <span class="hljs-comment"># Install only production dependencies</span></span>
<span class="hljs-keyword">CMD</span><span class="bash"> [<span class="hljs-string">"node"</span>, <span class="hljs-string">"dist/server.js"</span>] <span class="hljs-comment"># Run the built application</span></span>
This approach keeps your production images lightweight and secure by excluding unnecessary build tools and dev dependencies.
3. Optimizing Layer Caching
For even faster builds, order your Dockerfile
instructions to maximize layer caching. Copy and install dependencies before copying your full source code.
This way, Docker reuses the cached npm install step if your dependencies haven’t changed, even if you edit your app’s code:
First:
COPY package*.json ./
Then:
RUN npm ci
Finally:
COPY . .
4. Enable BuildKit for Faster Builds
Docker BuildKit is a newer build engine that enables features like better caching, parallel build steps, and overall faster builds.
To enable BuildKit during your CI, run:
- name: Build Docker image
<span class="hljs-keyword">run</span><span class="bash">: |</span>
<span class="hljs-comment"># Enable BuildKit for parallel and more efficient builds</span>
DOCKER_BUILDKIT=<span class="hljs-number">1</span> docker build -t myapp:latest .
Turning on BuildKit can significantly speed up complex Docker builds and is highly recommended for all CI pipelines.
Infrastructure as Code Using Terraform and Free Cloud Providers
Why Infrastructure as Code (IaC) Matters
When you manage infrastructure manually – that is, clicking around cloud dashboards or setting things up by hand – it’s easy to lose track of what you did and how to repeat it.
Infrastructure as Code (IaC) solves this by letting you define your infrastructure with code, version it just like application code, and track every change over time. This makes your setups easy to replicate across environments (development, staging, production), ensures changes are declarative and auditable, and reduces human error.
Whether you are spinning up a single server or scaling a complex system, IaC lays the foundation for professional-grade infrastructure from day one, letting you automate, document, and grow your environment systematically.
How to Provision Infrastructure with Terraform
Initialize a Terraform Project
First, define the providers and versions you need. Here, we’re using Render’s free cloud hosting service:
<span class="hljs-comment"># Define required providers and versions</span>
<span class="hljs-string">terraform</span> {
<span class="hljs-string">required_providers</span> {
<span class="hljs-string">render</span> <span class="hljs-string">=</span> {
<span class="hljs-string">source</span> <span class="hljs-string">=</span> <span class="hljs-string">"renderinc/render"</span> <span class="hljs-comment"># Using Render's free tier</span>
<span class="hljs-string">version</span> <span class="hljs-string">=</span> <span class="hljs-string">"0.1.0"</span> <span class="hljs-comment"># Specify provider version for stability</span>
}
}
}
<span class="hljs-comment"># Configure the Render provider with authentication</span>
<span class="hljs-string">provider</span> <span class="hljs-string">"render"</span> {
<span class="hljs-string">api_key</span> <span class="hljs-string">=</span> <span class="hljs-string">var.render_api_key</span> <span class="hljs-comment"># Store API key as a variable</span>
}
Then, configure the provider by authenticating with your API key. It is best practice to store secrets like API keys in variables instead of hardcoding them. This setup tells Terraform what platform you’re working with (Render) and how to authenticate to manage resources automatically.
Provision a Web App on Render
Next, define the infrastructure you want – in this case, a web service hosted on Render:
<span class="hljs-comment"># Define a web service on Render's free tier</span>
<span class="hljs-string">resource</span> <span class="hljs-string">"render_service"</span> <span class="hljs-string">"web_app"</span> {
<span class="hljs-string">name</span> <span class="hljs-string">=</span> <span class="hljs-string">"ci-demo-app"</span> <span class="hljs-comment"># Service name</span>
<span class="hljs-string">type</span> <span class="hljs-string">=</span> <span class="hljs-string">"web_service"</span> <span class="hljs-comment"># Type of service</span>
<span class="hljs-string">repo</span> <span class="hljs-string">=</span> <span class="hljs-string">"https://github.com/YOUR-USERNAME/YOUR-REPO"</span> <span class="hljs-comment"># Source repo</span>
<span class="hljs-string">env</span> <span class="hljs-string">=</span> <span class="hljs-string">"docker"</span> <span class="hljs-comment"># Use Docker environment</span>
<span class="hljs-string">plan</span> <span class="hljs-string">=</span> <span class="hljs-string">"starter"</span> <span class="hljs-comment"># Free tier plan</span>
<span class="hljs-string">branch</span> <span class="hljs-string">=</span> <span class="hljs-string">"main"</span> <span class="hljs-comment"># Deploy from main branch</span>
<span class="hljs-string">build_command</span> <span class="hljs-string">=</span> <span class="hljs-string">"docker build -t app ."</span> <span class="hljs-comment"># Build command</span>
<span class="hljs-string">start_command</span> <span class="hljs-string">=</span> <span class="hljs-string">"docker run -p 3000:3000 app"</span> <span class="hljs-comment"># Start command</span>
<span class="hljs-string">auto_deploy</span> <span class="hljs-string">=</span> <span class="hljs-literal">true</span> <span class="hljs-comment"># Auto-deploy on commits</span>
}
This resource block describes exactly how your app should be deployed. Whenever you change this file and reapply, Terraform will update the infrastructure to match.
Provision PostgreSQL for Free
Most applications need a database, but you don’t have to pay for one when you’re getting started. Platforms like Railway offer free tiers that are perfect for development and small projects.
You can quickly create a free PostgreSQL instance by signing up on the platform and clicking “Create New Project”. At the end, you’ll get a DATABASE_URL
a connection string that your app will use to talk to the database.
Connect App to DB
In Render (or whatever platform you’re using), set an environment variable called DATABASE_URL
and paste in the connection string from your PostgreSQL provider. This lets your application securely access the database without hardcoding credentials into your codebase.
Make it Reproducible
Once everything is defined, use Terraform to create and apply an infrastructure plan:
<span class="hljs-comment"># Create execution plan and save it to a file</span>
<span class="hljs-string">terraform</span> <span class="hljs-string">plan</span> <span class="hljs-string">-out=infra.tfplan</span>
<span class="hljs-comment"># Apply the saved plan exactly as planned</span>
<span class="hljs-string">terraform</span> <span class="hljs-string">apply</span> <span class="hljs-string">infra.tfplan</span>
Saving the plan to a file (infra.tfplan
) ensures you’re applying exactly what you reviewed, so there will be no surprises.
Common Issues and Fixes:
Provider not found: Run
terraform init
.API key error: Check
render_api_key
in Terraform Cloud variables.
How to Set Up Container Orchestration on Minimal Resources
When you’re working with limited resources like a laptop, a small server, or a lightweight cloud VM, setting up full Kubernetes can be overwhelming. Instead, you can use K3d, a lightweight Kubernetes distribution that runs inside Docker containers. Here’s how to set up a minimal, efficient cluster for local development or testing.
1. Install K3d for Local Kubernetes
First, install K3d. It’s a super lightweight way to run Kubernetes clusters inside Docker without needing a heavy setup like Minikube.
<span class="hljs-comment"># Download and install K3d - a lightweight K8s distribution</span>
curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
2. Create a Lightweight K3d Cluster
Once K3d is installed, you can spin up a cluster with minimal nodes to save resources.
<span class="hljs-comment"># Create a minimal K8s cluster with 1 server and 2 agent nodes</span>
k3d cluster create dev-cluster
--servers 1 <span class="hljs-comment"># Single server node to minimize resource usage</span>
--agents 2 <span class="hljs-comment"># Two worker nodes for pod distribution</span>
--volume /tmp/k3dvol:/tmp/k3dvol <span class="hljs-comment"># Mount local volume for persistence</span>
--port 8080:80@loadbalancer <span class="hljs-comment"># Map port 8080 locally to 80 in the cluster</span>
--api-port 6443 <span class="hljs-comment"># Set the API port</span>
This setup gives you a tiny but real Kubernetes cluster that is perfect for experimentation.
3. Deploy with Optimized Kubernetes Manifests
Now that your cluster is running, you can deploy your app. It’s important to define resource requests and limits carefully so your pods don’t consume too much memory or CPU.
<span class="hljs-comment"># Resource-optimized deployment manifest</span>
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp <span class="hljs-comment"># Name of the deployment</span>
spec:
replicas: 1 <span class="hljs-comment"># Single replica to save resources</span>
selector:
matchLabels:
app: webapp
template:
metadata:
labels:
app: webapp
spec:
containers:
- name: app
image: myapp:latest
resources:
<span class="hljs-comment"># Set minimal resource requests</span>
requests:
memory: <span class="hljs-string">"64Mi"</span> <span class="hljs-comment"># Request only 64MB memory</span>
cpu: <span class="hljs-string">"50m"</span> <span class="hljs-comment"># Request only 5% of a CPU core</span>
<span class="hljs-comment"># Set reasonable limits</span>
limits:
memory: <span class="hljs-string">"128Mi"</span> <span class="hljs-comment"># Limit to 128MB memory</span>
cpu: <span class="hljs-string">"100m"</span> <span class="hljs-comment"># Limit to 10% of a CPU core</span>
This ensures Kubernetes knows how much to allocate and avoid overloading your lightweight environment.
4. Set up GitOps with Flux
To manage deployments automatically from your GitHub repository, you can set up GitOps using Flux.
<span class="hljs-comment"># Install Flux CLI</span>
brew install fluxcd/tap/flux
<span class="hljs-comment"># Bootstrap Flux on your cluster connected to your GitHub repository</span>
flux bootstrap github
--owner=YOUR_GITHUB_USERNAME <span class="hljs-comment"># Your GitHub username</span>
--repository=YOUR_REPO_NAME <span class="hljs-comment"># Repository to store Flux manifests</span>
--branch=main <span class="hljs-comment"># Branch to use</span>
--path=clusters/dev-cluster <span class="hljs-comment"># Path within repo for cluster configs</span>
--personal <span class="hljs-comment"># Flag for personal account</span>
Flux watches your repo and applies updates to your cluster, keeping everything declarative and reproducible.
Common Issues and Fixes:
Pods crash: Run
kubectl logs pod-name
or increase resources.Flux sync fails: Check GitHub token permissions.
How to Create a Free Deployment Pipeline
Like I said initially, not every project needs expensive infrastructure. If you’re just getting started or building side projects, free tiers from cloud providers can cover a lot of ground.
1. Understanding Free Tier Limitations
Here’s a quick overview of popular cloud free tiers:
Provider | Free Tier Highlights |
AWS Free Tier | 750 hours/month EC2, 5GB S3, 1M Lambda requests |
Oracle Cloud Free Tier | 2 always-free compute instances, 30GB storage |
Google Cloud Free Tier | 1 f1-micro instance, 5GB storage |
Knowing these limits helps you stay within budget.
2. Set Up Deployment Workflows
You can automate deployments with GitHub Actions. Here’s an example of a deployment workflow to AWS:
<span class="hljs-comment"># GitHub Action workflow for deploying to AWS</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">AWS</span> <span class="hljs-string">Deployment</span>
<span class="hljs-attr">on:</span>
<span class="hljs-attr">push:</span>
<span class="hljs-attr">branches:</span>
<span class="hljs-bullet">-</span> <span class="hljs-string">main</span> <span class="hljs-comment"># Deploy on push to main branch</span>
<span class="hljs-attr">jobs:</span>
<span class="hljs-attr">deploy:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span> <span class="hljs-comment"># Check out code</span>
<span class="hljs-comment"># Set up AWS credentials from GitHub secrets</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Set</span> <span class="hljs-string">up</span> <span class="hljs-string">AWS</span> <span class="hljs-string">credentials</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">aws-actions/configure-aws-credentials@v1</span>
<span class="hljs-attr">with:</span>
<span class="hljs-attr">aws-access-key-id:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.AWS_ACCESS_KEY_ID</span> <span class="hljs-string">}}</span>
<span class="hljs-attr">aws-secret-access-key:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.AWS_SECRET_ACCESS_KEY</span> <span class="hljs-string">}}</span>
<span class="hljs-attr">aws-region:</span> <span class="hljs-string">us-east-1</span>
<span class="hljs-comment"># Build the Docker image</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Build</span> <span class="hljs-string">Docker</span> <span class="hljs-string">Image</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">docker</span> <span class="hljs-string">build</span> <span class="hljs-string">-t</span> <span class="hljs-string">myapp</span> <span class="hljs-string">.</span>
<span class="hljs-comment"># Push the image to AWS ECR</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Push</span> <span class="hljs-string">Docker</span> <span class="hljs-string">Image</span> <span class="hljs-string">to</span> <span class="hljs-string">ECR</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">|
# Create repository if it doesn't exist (ignoring errors if it does)
aws ecr create-repository --repository-name myapp || true
</span>
<span class="hljs-comment"># Login to ECR</span>
<span class="hljs-string">aws</span> <span class="hljs-string">ecr</span> <span class="hljs-string">get-login-password</span> <span class="hljs-string">|</span> <span class="hljs-string">docker</span> <span class="hljs-string">login</span> <span class="hljs-string">--username</span> <span class="hljs-string">AWS</span> <span class="hljs-string">--password-stdin</span> <span class="hljs-string"><aws_account_id>.dkr.ecr.us-east-1.amazonaws.com</span>
<span class="hljs-comment"># Tag and push the image</span>
<span class="hljs-string">docker</span> <span class="hljs-string">tag</span> <span class="hljs-string">myapp:latest</span> <span class="hljs-string"><aws_account_id>.dkr.ecr.us-east-1.amazonaws.com/myapp:latest</span>
<span class="hljs-string">docker</span> <span class="hljs-string">push</span> <span class="hljs-string"><aws_account_id>.dkr.ecr.us-east-1.amazonaws.com/myapp:latest</span>
3. Implement Zero-Downtime Deployments
Zero downtime is crucial. Kubernetes makes this easy with rolling updates:
<span class="hljs-comment"># Kubernetes deployment configured for zero-downtime updates</span>
<span class="hljs-attr">apiVersion:</span> <span class="hljs-string">apps/v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Deployment</span>
<span class="hljs-attr">metadata:</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">crud-app</span>
<span class="hljs-attr">spec:</span>
<span class="hljs-attr">replicas:</span> <span class="hljs-number">3</span> <span class="hljs-comment"># Multiple replicas for high availability</span>
<span class="hljs-attr">selector:</span>
<span class="hljs-attr">matchLabels:</span>
<span class="hljs-attr">app:</span> <span class="hljs-string">crud-app</span>
<span class="hljs-attr">template:</span>
<span class="hljs-attr">metadata:</span>
<span class="hljs-attr">labels:</span>
<span class="hljs-attr">app:</span> <span class="hljs-string">crud-app</span>
<span class="hljs-attr">spec:</span>
<span class="hljs-attr">containers:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">app</span>
<span class="hljs-attr">image:</span> <span class="hljs-string"><docker_registry>/crud-app:latest</span>
<span class="hljs-attr">ports:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">containerPort:</span> <span class="hljs-number">80</span> <span class="hljs-comment"># Expose container port</span>
By having multiple replicas, you ensure that some pods stay live during updates.
4. Create Cross-Cloud Deployment for Redundancy
If you want better reliability, you can deploy across different clouds in parallel:
<span class="hljs-comment"># Deploy to multiple cloud providers for redundancy</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">Cross-Cloud</span> <span class="hljs-string">Deployment</span>
<span class="hljs-attr">on:</span>
<span class="hljs-attr">push:</span>
<span class="hljs-attr">branches:</span>
<span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
<span class="hljs-attr">jobs:</span>
<span class="hljs-comment"># Deploy to AWS</span>
<span class="hljs-attr">aws-deploy:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">AWS</span> <span class="hljs-string">Setup</span> <span class="hljs-string">&</span> <span class="hljs-string">Deploy</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">|
# Configure AWS CLI with credentials
aws configure set aws_access_key_id ${{ secrets.AWS_ACCESS_KEY_ID }}
aws configure set aws_secret_access_key ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS deployment commands...
</span>
<span class="hljs-comment"># Deploy to Oracle Cloud in parallel</span>
<span class="hljs-attr">oracle-deploy:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Oracle</span> <span class="hljs-string">Setup</span> <span class="hljs-string">&</span> <span class="hljs-string">Deploy</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">|
# Configure Oracle Cloud CLI
oci setup config
# Oracle Cloud deployment commands...</span>
Now if one cloud goes down, the other is still up.
5. Implement Automated Rollbacks with Health Checks
Set up health checks so Kubernetes can automatically rollback if something goes wrong:
<span class="hljs-comment"># Deployment with health checks for automated rollbacks</span>
<span class="hljs-attr">apiVersion:</span> <span class="hljs-string">apps/v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Deployment</span>
<span class="hljs-attr">metadata:</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">crud-app</span>
<span class="hljs-attr">spec:</span>
<span class="hljs-attr">replicas:</span> <span class="hljs-number">3</span>
<span class="hljs-attr">selector:</span>
<span class="hljs-attr">matchLabels:</span>
<span class="hljs-attr">app:</span> <span class="hljs-string">crud-app</span>
<span class="hljs-attr">template:</span>
<span class="hljs-attr">metadata:</span>
<span class="hljs-attr">labels:</span>
<span class="hljs-attr">app:</span> <span class="hljs-string">crud-app</span>
<span class="hljs-attr">spec:</span>
<span class="hljs-attr">containers:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">crud-app</span>
<span class="hljs-attr">image:</span> <span class="hljs-string"><docker_registry>/crud-app:latest</span>
<span class="hljs-attr">ports:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">containerPort:</span> <span class="hljs-number">80</span>
<span class="hljs-comment"># Check if the container is alive</span>
<span class="hljs-attr">livenessProbe:</span>
<span class="hljs-attr">httpGet:</span>
<span class="hljs-attr">path:</span> <span class="hljs-string">/healthz</span> <span class="hljs-comment"># Health check endpoint</span>
<span class="hljs-attr">port:</span> <span class="hljs-number">80</span>
<span class="hljs-attr">initialDelaySeconds:</span> <span class="hljs-number">5</span> <span class="hljs-comment"># Wait before first check</span>
<span class="hljs-attr">periodSeconds:</span> <span class="hljs-number">10</span> <span class="hljs-comment"># Check every 10 seconds</span>
<span class="hljs-comment"># Check if the container is ready to receive traffic</span>
<span class="hljs-attr">readinessProbe:</span>
<span class="hljs-attr">httpGet:</span>
<span class="hljs-attr">path:</span> <span class="hljs-string">/readiness</span> <span class="hljs-comment"># Readiness check endpoint</span>
<span class="hljs-attr">port:</span> <span class="hljs-number">80</span>
<span class="hljs-attr">initialDelaySeconds:</span> <span class="hljs-number">5</span> <span class="hljs-comment"># Wait before first check</span>
<span class="hljs-attr">periodSeconds:</span> <span class="hljs-number">10</span> <span class="hljs-comment"># Check every 10 seconds</span>
How to Build a Comprehensive Monitoring System
Even with a small deployment, monitoring is key to spotting issues early. So now, I’ll walk through setting up a comprehensive monitoring system for your application.
You’ll learn how to integrate Grafana Cloud for visualizing your metrics, use Prometheus for collecting data, and configure custom alerts to monitor your app’s performance. I’ll also cover tracking Service Level Objectives (SLOs) and setting up external monitoring with UptimeRobot to make sure that your endpoints are always available.
1. Set Up Grafana Cloud’s Free Tier
Create a Grafana Cloud account and connect Prometheus as a data source. They offer generous free usage, which is perfect for small teams.
2. Configure Prometheus for Metrics Collection
Prometheus collects metrics from your app.
<span class="hljs-comment"># prometheus.yml - Basic Prometheus configuration</span>
<span class="hljs-attr">global:</span>
<span class="hljs-attr">scrape_interval:</span> <span class="hljs-string">15s</span> <span class="hljs-comment"># Collect metrics every 15 seconds</span>
<span class="hljs-attr">scrape_configs:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">job_name:</span> <span class="hljs-string">'crud-app'</span> <span class="hljs-comment"># Job name for the crud-app metrics</span>
<span class="hljs-attr">static_configs:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">targets:</span> [<span class="hljs-string">'localhost:8080'</span>] <span class="hljs-comment"># Where to collect metrics from</span>
This scrapes your app every 15 seconds for metrics.
3. Create Monitoring Dashboards
Grafana visualizes Prometheus data. You can create dashboards using queries like:
<span class="hljs-comment"># Calculate average CPU usage rate per instance over 1 minute</span>
<span class="hljs-string">avg(rate(cpu_usage_seconds_total[1m]))</span> <span class="hljs-string">by</span> <span class="hljs-string">(instance)</span>
This calculates average CPU usage over the last minute per instance.
4. Write Custom PromQL Queries for Alerts
You can create smart alerts to detect increasing error rates, like the below:
<span class="hljs-comment"># Calculate error rate as a percentage of total requests</span>
<span class="hljs-comment"># Alert when error rate exceeds 5%</span>
<span class="hljs-string">sum(rate(http_requests_total{status=~"5.."}[5m]))</span> <span class="hljs-string">by</span> <span class="hljs-string">(service)</span>
<span class="hljs-string">/</span>
<span class="hljs-string">sum(rate(http_requests_total[5m]))</span> <span class="hljs-string">by</span> <span class="hljs-string">(service)</span> <span class="hljs-string">></span> <span class="hljs-number">0.05</span>
This alerts if more than 5% of your traffic results in errors.
5. Implement SLO Tracking on a Budget
You can track Service Level Objectives (SLOs) with Prometheus for free:
<span class="hljs-comment"># Calculate percentage of requests completed under 200ms</span>
<span class="hljs-comment"># Alert when it drops below 99%</span>
<span class="hljs-string">rate(http_request_duration_seconds_bucket{le="0.2"}[5m])</span>
<span class="hljs-string">/</span> <span class="hljs-string">rate(http_request_duration_seconds_count[5m])</span>
<span class="hljs-string">></span> <span class="hljs-number">0.99</span>
This tracks if 99% of requests complete in under 200ms.
6. Set Up UptimeRobot for External Monitoring
Finally, you can use UptimeRobot to check if your endpoints are reachable externally, and get alerts if anything goes down.
How to Implement Security Testing and Scanning
Security should be integrated into your development pipeline from the start, not added as an afterthought. In this section, I’ll show you how to implement security testing and scanning at various stages of your workflow.
You’ll use GitHub CodeQL for static code analysis, OWASP ZAP for scanning web vulnerabilities, and Trivy for container image scanning. You’ll also learn how to enforce security thresholds directly in your CI pipeline.
1. Enable GitHub Code Scanning with CodeQL
GitHub has built-in code scanning with CodeQL. Here’s how to set it up:
<span class="hljs-comment"># GitHub workflow for CodeQL security scanning</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">CodeQL</span>
<span class="hljs-attr">on:</span>
<span class="hljs-attr">push:</span>
<span class="hljs-attr">branches:</span>
<span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
<span class="hljs-attr">pull_request:</span>
<span class="hljs-attr">branches:</span>
<span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
<span class="hljs-attr">jobs:</span>
<span class="hljs-attr">analyze:</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">Analyze</span> <span class="hljs-string">code</span> <span class="hljs-string">with</span> <span class="hljs-string">CodeQL</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">code</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-comment"># Initialize the CodeQL scanning tools</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Set</span> <span class="hljs-string">up</span> <span class="hljs-string">CodeQL</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">github/codeql-action/init@v2</span>
<span class="hljs-comment"># Run the analysis and generate results</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Analyze</span> <span class="hljs-string">code</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">github/codeql-action/analyze@v2</span>
This automatically checks your code for security vulnerabilities.
2. Integrate OWASP ZAP into Your CI Pipeline
You can also scan your deployed app with OWASP ZAP like this:
<span class="hljs-comment"># Automated security scanning with OWASP ZAP</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">ZAP</span> <span class="hljs-string">Scan</span>
<span class="hljs-attr">on:</span>
<span class="hljs-attr">push:</span>
<span class="hljs-attr">branches:</span>
<span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
<span class="hljs-attr">jobs:</span>
<span class="hljs-attr">zap-scan:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">code</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-comment"># Run the ZAP security scan against deployed application</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">ZAP</span> <span class="hljs-string">security</span> <span class="hljs-string">scan</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">zaproxy/action-full-scan@v0.3.0</span>
<span class="hljs-attr">with:</span>
<span class="hljs-attr">target:</span> <span class="hljs-string">'https://yourapp.com'</span> <span class="hljs-comment"># URL to scan</span>
This checks for common web vulnerabilities.
3. Set Up Trivy for Container Vulnerability Scanning
You can also check your container images for vulnerabilities with Trivy:
<span class="hljs-comment"># Scan Docker images for vulnerabilities using Trivy</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">Trivy</span> <span class="hljs-string">vulnerability</span> <span class="hljs-string">scanner</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">aquasecurity/trivy-action@master</span>
<span class="hljs-attr">with:</span>
<span class="hljs-attr">image-ref:</span> <span class="hljs-string">'crud-app:latest'</span> <span class="hljs-comment"># Image to scan</span>
<span class="hljs-attr">format:</span> <span class="hljs-string">'table'</span> <span class="hljs-comment"># Output format</span>
<span class="hljs-attr">exit-code:</span> <span class="hljs-string">'1'</span> <span class="hljs-comment"># Fail the build if vulnerabilities found</span>
<span class="hljs-attr">ignore-unfixed:</span> <span class="hljs-literal">true</span> <span class="hljs-comment"># Skip vulnerabilities without fixes</span>
<span class="hljs-attr">severity:</span> <span class="hljs-string">'CRITICAL,HIGH'</span> <span class="hljs-comment"># Only alert on critical and high severity</span>
Your builds will fail if serious issues are found, keeping you safe by default.
4. Create Threshold-Based Pipeline Failures
You can configure your pipelines to fail automatically if vulnerabilities exceed a set threshold, enforcing strong security practices without manual effort. Here’s how that should look:
<span class="hljs-comment"># Fail the pipeline if critical or high vulnerabilities are found</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">Trivy</span> <span class="hljs-string">vulnerability</span> <span class="hljs-string">scanner</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">aquasecurity/trivy-action@master</span>
<span class="hljs-attr">with:</span>
<span class="hljs-attr">image-ref:</span> <span class="hljs-string">'crud-app:latest'</span> <span class="hljs-comment"># Image to scan</span>
<span class="hljs-attr">format:</span> <span class="hljs-string">'json'</span> <span class="hljs-comment"># Output as JSON for parsing</span>
<span class="hljs-attr">exit-code:</span> <span class="hljs-string">'1'</span> <span class="hljs-comment"># Fail the build if vulnerabilities found</span>
<span class="hljs-attr">severity:</span> <span class="hljs-string">'CRITICAL,HIGH'</span> <span class="hljs-comment"># Check for critical and high severity issues</span>
<span class="hljs-attr">ignore-unfixed:</span> <span class="hljs-literal">true</span> <span class="hljs-comment"># Skip vulnerabilities without fixes</span>
This forces a no-compromise security posture – that is, if critical or high vulnerabilities are detected, the build stops immediately.
5. Implement Custom Security Checks
Sometimes you need to go beyond automated scanners. Here’s a basic example of a custom security check you can add to your pipeline:
<span class="hljs-comment">#!/bin/bash</span>
<span class="hljs-comment"># Custom script to check for hard-coded secrets in source code</span>
<span class="hljs-comment"># Check for hard-coded API keys in source files</span>
<span class="hljs-string">if</span> <span class="hljs-string">grep</span> <span class="hljs-string">-r</span> <span class="hljs-string">"API_KEY"</span> <span class="hljs-string">./src;</span> <span class="hljs-string">then</span>
<span class="hljs-string">echo</span> <span class="hljs-string">"Security issue: Found hard-coded API keys."</span>
<span class="hljs-string">exit</span> <span class="hljs-number">1</span> <span class="hljs-comment"># Fail the build</span>
<span class="hljs-string">else</span>
<span class="hljs-string">echo</span> <span class="hljs-string">"No hard-coded API keys found."</span>
<span class="hljs-string">fi</span>
You can extend this script to scan for patterns like private keys, passwords, or other sensitive information, helping catch issues before they ever reach production.
Performance Optimization and Scaling
Optimizing early saves you pain later. Here’s how to make your pipelines faster, smarter, and more scalable:
1. Measure Pipeline Execution Times
Understanding how long each step takes is the first step to improving it:
<span class="hljs-attr">jobs:</span>
<span class="hljs-attr">build:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-comment"># Record the start time</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Start</span> <span class="hljs-string">timer</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">echo</span> <span class="hljs-string">"Start time: $(date)"</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">install</span>
<span class="hljs-comment"># Record the end time to calculate duration</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">End</span> <span class="hljs-string">timer</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">echo</span> <span class="hljs-string">"End time: $(date)"</span>
Later, you can automate time tracking for full reports and alerts.
2. Implement Parallelization Strategies
Split your jobs smartly to save time:
<span class="hljs-attr">jobs:</span>
<span class="hljs-comment"># First job to install dependencies</span>
<span class="hljs-attr">install:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">ci</span>
<span class="hljs-comment"># Run tests in parallel with linting</span>
<span class="hljs-attr">test:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">needs:</span> <span class="hljs-string">install</span> <span class="hljs-comment"># Depends on install job</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">test</span>
<span class="hljs-comment"># Run linting in parallel with tests</span>
<span class="hljs-attr">lint:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">needs:</span> <span class="hljs-string">install</span> <span class="hljs-comment"># Also depends on install job</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">run</span> <span class="hljs-string">lint</span>
Result: Testing and linting run in parallel after installing dependencies, cutting pipeline time significantly.
3. Set Up Distributed Caching
Caching saves your workflow from repeating expensive tasks:
<span class="hljs-comment"># Cache dependencies to speed up builds</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Cache</span> <span class="hljs-string">node</span> <span class="hljs-string">modules</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">actions/cache@v3</span>
<span class="hljs-attr">with:</span>
<span class="hljs-attr">path:</span> <span class="hljs-string">|
~/.npm # Cache global npm cache
node_modules # Cache local dependencies
</span> <span class="hljs-attr">key:</span> <span class="hljs-string">${{</span> <span class="hljs-string">runner.os</span> <span class="hljs-string">}}-node-${{</span> <span class="hljs-string">hashFiles('**/package-lock.json')</span> <span class="hljs-string">}}</span> <span class="hljs-comment"># Key based on OS and dependency hash</span>
<span class="hljs-attr">restore-keys:</span> <span class="hljs-string">|</span> <span class="hljs-comment"># Fallback keys if exact match isn't found</span>
<span class="hljs-string">${{</span> <span class="hljs-string">runner.os</span> <span class="hljs-string">}}-node-</span>
Tip: Also cache build artifacts, Docker layers, and Terraform plans when possible.
4. Create Performance Benchmarks
Track your build times over time with benchmarks:
<span class="hljs-attr">jobs:</span>
<span class="hljs-attr">build:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-comment"># Store the start time as an environment variable</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Start</span> <span class="hljs-string">timer</span>
<span class="hljs-attr">id:</span> <span class="hljs-string">start_time</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">echo</span> <span class="hljs-string">"start_time=$(date +%s)"</span> <span class="hljs-string">>></span> <span class="hljs-string">$GITHUB_ENV</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">install</span>
<span class="hljs-comment"># Calculate and display the elapsed time</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">End</span> <span class="hljs-string">timer</span> <span class="hljs-string">and</span> <span class="hljs-string">calculate</span> <span class="hljs-string">elapsed</span> <span class="hljs-string">time</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">|
end_time=$(date +%s)
elapsed_time=$((end_time - ${{ env.start_time }}))
echo "Build time: $elapsed_time seconds"</span>
With benchmarks in place, you can monitor regressions and trigger optimizations automatically.
5. How to Plan for Growth Beyond Free Tiers
Understand cloud pricing structures: AWS, Azure, GCP all offer generous free tiers, but know the limits to avoid surprise bills. (I have been there and it wasn’t pretty.)
Consider scaling to more advanced CI/CD tools: Jenkins, CircleCI, GitLab can offer better performance or self-hosted control as you grow.
Automate resource provisioning: Use Infrastructure as Code (IaC) with Terraform, Pulumi, or AWS CDK to dynamically scale your infrastructure when your team or traffic grows.
Complete CI/CD Pipeline Example
Here’s a full example tying everything together:
<span class="hljs-comment"># Complete end-to-end CI/CD pipeline</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">CI/CD</span> <span class="hljs-string">Pipeline</span>
<span class="hljs-attr">on:</span>
<span class="hljs-attr">push:</span>
<span class="hljs-attr">branches:</span>
<span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
<span class="hljs-attr">jobs:</span>
<span class="hljs-comment"># Initial setup job</span>
<span class="hljs-attr">setup:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">code</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
<span class="hljs-comment"># Build and test job</span>
<span class="hljs-attr">build:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">needs:</span> <span class="hljs-string">setup</span> <span class="hljs-comment"># Depends on setup job</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Setup</span> <span class="hljs-string">Node.js</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-node@v3</span>
<span class="hljs-attr">with:</span>
<span class="hljs-attr">node-version:</span> <span class="hljs-string">'16'</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Install</span> <span class="hljs-string">dependencies</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">install</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">security</span> <span class="hljs-string">scan</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">npx</span> <span class="hljs-string">eslint</span> <span class="hljs-string">.</span> <span class="hljs-comment"># Run ESLint for security rules</span>
<span class="hljs-comment"># Deploy to Kubernetes job</span>
<span class="hljs-attr">deploy:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">needs:</span> <span class="hljs-string">build</span> <span class="hljs-comment"># Depends on successful build</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Setup</span> <span class="hljs-string">K3d</span> <span class="hljs-string">cluster</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">k3d</span> <span class="hljs-string">cluster</span> <span class="hljs-string">create</span> <span class="hljs-string">dev-cluster</span> <span class="hljs-string">--servers</span> <span class="hljs-number">1</span> <span class="hljs-string">--agents</span> <span class="hljs-number">2</span> <span class="hljs-string">--port</span> <span class="hljs-number">8080</span><span class="hljs-string">:80@loadbalancer</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Apply</span> <span class="hljs-string">Kubernetes</span> <span class="hljs-string">manifests</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">kubectl</span> <span class="hljs-string">apply</span> <span class="hljs-string">-f</span> <span class="hljs-string">k8s/</span> <span class="hljs-comment"># Apply all K8s manifests in the k8s directory</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Deploy</span> <span class="hljs-string">app</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">kubectl</span> <span class="hljs-string">rollout</span> <span class="hljs-string">restart</span> <span class="hljs-string">deployment/webapp</span> <span class="hljs-comment"># Restart deployment for zero-downtime update</span>
<span class="hljs-comment"># Infrastructure provisioning job</span>
<span class="hljs-attr">terraform:</span>
<span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
<span class="hljs-attr">needs:</span> <span class="hljs-string">deploy</span> <span class="hljs-comment"># Run after deployment</span>
<span class="hljs-attr">steps:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Setup</span> <span class="hljs-string">Terraform</span>
<span class="hljs-attr">uses:</span> <span class="hljs-string">hashicorp/setup-terraform@v2</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Terraform</span> <span class="hljs-string">Init</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">terraform</span> <span class="hljs-string">init</span> <span class="hljs-comment"># Initialize Terraform</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Terraform</span> <span class="hljs-string">Apply</span>
<span class="hljs-attr">run:</span> <span class="hljs-string">terraform</span> <span class="hljs-string">apply</span> <span class="hljs-string">-auto-approve</span> <span class="hljs-comment"># Apply infrastructure changes automatically</span>
Runbook: Failed Deployment:
Issue: Pods fail due to resource limits (for example, OOMKilled, CrashLoopBackOff).
Fix:
<span class="hljs-string">kubectl</span> <span class="hljs-string">top</span> <span class="hljs-string">pod</span>
<span class="hljs-string">kubectl</span> <span class="hljs-string">edit</span> <span class="hljs-string">deployment</span> <span class="hljs-string">crud-app</span>
<span class="hljs-string">kubectl</span> <span class="hljs-string">apply</span> <span class="hljs-string">-f</span> <span class="hljs-string">deployment.yaml</span>
<span class="hljs-string">kubectl</span> <span class="hljs-string">rollout</span> <span class="hljs-string">status</span> <span class="hljs-string">deployment/crud-app</span>
Tip: Set realistic resource requests and limits early, it’ll save you debugging time later.
Conclusion
By following along with this tutorial, you now know how to build a production-ready DevOps pipeline using free tools:
CI/CD: GitHub Actions for testing, linting, and building.
Infrastructure: Terraform for AWS/Render and PostgreSQL setup.
Orchestration: K3d for local Kubernetes.
Monitoring: Grafana, Prometheus, UptimeRobot.
Security: CodeQL, OWASP ZAP, Trivy for vulnerability scanning.
This pipeline is scalable and secure, and it’s perfect for small projects. As your app grows, you might want to consider paid plans for more resources (for example, AWS larger instances, Grafana unlimited metrics). You can check AWS Free Tier, Terraform Docs, and Grafana Docs for more learning.
PS: I’d love to see what you build. Share your pipeline on FreeCodeCamp’s forum or tag me on X @Emidowojo with #DevOpsOnABudget, and tell me about the challenges you faced. You can also connect with me on LinkedIn if you’d like to stay in touch. If you made it to the end of this lengthy article, thanks for reading!
Source: freeCodeCamp Programming Tutorials: Python, JavaScript, Git & MoreÂ