Welcome to DevOps by Doing, a hands-on series where we don’t just talk about DevOps principles — we actually build, maintain and upgrade complete modern DevOps environments step by step. This is the first instalment in the series: Setting Up a Complete Modern DevOps Environment. By the end of this exercise series, you’ll have a working Node.js application running through a full DevOps pipeline: from source control to automated testing, containerization, deployment and more.
Instead of abstract theory, we’ll focus on practical implementation. We’ll start small — setting up our project and tools — and then gradually build out the pipeline into a production-ready workflow. Each article will layer on new pieces of the environment so you can follow along at your own pace.
What We’ll Cover in This Series
Over the course of this series (likely two to three parts), we’ll walk through the following:
Version Control Setup: Initializing Git, configuring .gitignore, and structuring the project repository.
Node.js Application Setup: Bootstrapping a simple sample app, installing dependencies, and writing tests.
Code Quality & Standards: Setting up ESLint and other linters for clean, consistent code.
Environment Management: Creating .env.example files to handle configuration safely.
Continuous Integration (CI): Configuring GitHub Actions to run builds, linting, and tests automatically.
Containerization: Writing a Dockerfile, setting up .dockerignore, and building images.
Local Development with Docker Compose: Creating a docker-compose.yml to run our app and services locally.
Continuous Deployment (CD): Extending our GitHub Actions pipeline to build, push, and deploy containers.
Part 1: Laying the Foundation
In this first article, we’ll focus on the core project setup:
Initializing Git
Setting up a sample Node.js app
Installing dependencies
Writing and running tests
Creating .gitignore and .env.example
Adding ESLint for code quality
Set up files for GitHub Actions CI/CD
Set up Dockerfile
Essential Configuration Files such as
.gitignore
and.dockerignore
By the end of this article, you’ll have a clean, version-controlled project with quality checks in place — ready to move on to Docker, GitHub Actions, and deployment in the upcoming parts.
Let’s get started. 🚀
Requirements
Before we dive in, let’s make sure your development environment is ready. To follow along with this series, you’ll need the following tools installed on your machine:
Git – for version control and repository management.
Node.js(LTS recommended) – we’ll use it to build our sample application. Installing Node will also give you npm (Node Package Manager).
Docker to containerize our application and manage images.
Docker Compose to run multi-container environments locally.
A GitHub account – since we’ll be pushing code to GitHub and setting up GitHub Actions for CI/CD.
-
A code editor (VS Code recommended) – with useful extensions such as:
- ESLint
- Docker
Verify Everything is Installed
Once you’ve installed the required tools, run the following commands in your terminal to make sure everything is set up correctly:
node --version
# Should show v18.x+ or v20.x+
npm --version
# Should show 9.x+ or 10.x+
git --version
# Should show 2.34+
docker --version
# Should show 24.x+
Step 1: Set Up Git for Version Control
What this step does: Configures Git on your machine so it knows who you are when you make commits, and sets up proper project tracking.
Git is the backbone of most modern DevOps workflows. It keeps track of changes to your codebase, allows collaboration, and integrates directly with CI/CD pipelines like GitHub Actions.
1.1 Configure Git (if you haven’t already)
Run the following commands to tell Git who you are:
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"
You can confirm your settings with:
git config --list
💡 Use the same email that’s linked to your GitHub account — it makes authentication and contribution tracking easier.
1.2 Initialize a Git Repository
Create and navigate to the folder where you’ll be creating the project and run:
mkdir my-devops-project
cd my-devops-project
git init
This creates a new folder called my-devops-project
, moves you inside it, and initializes it as a Git repository. The git init
command sets up all the hidden files Git needs (.git/), allowing you to start tracking changes to your project.
To confirm that Git has created the repository metadata, list all files (including hidden ones):
ls -a
Expected output (your system may show additional files):
. .. .git
1.3 Verify Repository Setup
Check that everything is working by running:
git status
You should see a message saying “On branch main” (or master, depending on your Git version) and that there are no commits yet.
At this point, Git is ready — next we’ll set up our Node.js application inside this repository. 🚀
Step 2: Build a Node.js Web App
What this step does: Creates a web application using Node.js that can serve web pages and API endpoints.
2.1 Initialize a Node.js Project
What this step does: Creates a package.json
file that describes your project and manages dependencies.
Run the following command inside your project folder:
npm init -y
This generates a package.json
file with default values.
2.2 Update package.json
What this step does: Customizes the package.json with proper scripts and metadata for your DevOps project.
Open the file in your editor (VS Code, for example), or recreate it with:
touch package.json
Then copy and replace the contents with the following:
{
"name": "my-devops-project",
"version": "1.0.0",
"description": "DevOps learning project with Node.js",
"main": "app.js",
"scripts": {
"start": "node app.js",
"test": "jest",
"dev": "node app.js",
"lint": "eslint ."
},
"keywords": ["devops", "nodejs", "docker"],
"author": "Your Name",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"jest": "^29.7.0",
"eslint": "^8.57.0",
"supertest": "^7.1.4"
}
}
Step 3: Create the Node.js Web Server
What this step does: Creates an HTTP server that listens on port 3000, serves different endpoints (/, /health, /info, /metrics), includes security headers and error handling, provides graceful shutdown capability, and exports the server for testing.
3.1 Create a file calledapp.js
. You can do this in VS code or by typing in the terminal:
touch app.js
Copy the following code into a new file called app.js
:
const http = require('http');
const url = require('url');
const port = process.env.PORT || 3000;
const environment = process.env.NODE_ENV || 'development';
let requestCount = 0;
const startTime = Date.now();
// Enhanced Web Server
const server = http.createServer((req, res) => {
requestCount++;
const timestamp = new Date().toISOString();
const { pathname } = url.parse(req.url, true);
console.log(`${timestamp} - ${req.method} ${pathname} - ${req.headers['user-agent'] || 'Unknown'}`);
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
// Security headers
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
// Route handling
switch (pathname) {
case '/':
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>DevOps Lab 2025</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px; }
.endpoint { background: #f8f9fa; padding: 15px; margin: 10px 0; border-radius: 5px; border-left: 4px solid #007bff; }
</style>
</head>
<body>
<div class="header">
<h1>I'm Getting Better at DevOps, Yay!</h1>
<p>Modern Node.js application with CI/CD pipeline</p>
</div>
<h2>Available Endpoints:</h2>
<div class="endpoint">
<strong>GET /</strong> - This welcome page
</div>
<div class="endpoint">
<strong>GET /health</strong> - Health check (JSON)
</div>
<div class="endpoint">
<strong>GET /info</strong> - System information
</div>
<div class="endpoint">
<strong>GET /metrics</strong> - Prometheus metrics
</div>
<p>Environment: <strong>${environment}</strong></p>
<p>Server time: <strong>${timestamp}</strong></p>
<p>Requests served: <strong>${requestCount}</strong></p>
</body>
</html>
`);
break;
case '/health':
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: environment,
version: '1.0.0',
node_version: process.version,
requests_served: requestCount
}, null, 2));
break;
case '/info':
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
platform: process.platform,
architecture: process.arch,
node_version: process.version,
memory_usage: process.memoryUsage(),
environment: environment,
pid: process.pid,
uptime: process.uptime()
}, null, 2));
break;
case '/metrics':
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(`# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total ${requestCount}
# HELP app_uptime_seconds Application uptime in seconds
# TYPE app_uptime_seconds gauge
app_uptime_seconds ${process.uptime()}
# HELP nodejs_memory_usage_bytes Node.js memory usage
# TYPE nodejs_memory_usage_bytes gauge
nodejs_memory_usage_bytes{type="rss"} ${process.memoryUsage().rss}
nodejs_memory_usage_bytes{type="heapUsed"} ${process.memoryUsage().heapUsed}
nodejs_memory_usage_bytes{type="heapTotal"} ${process.memoryUsage().heapTotal}
nodejs_memory_usage_bytes{type="external"} ${process.memoryUsage().external}
`);
break;
default:
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
error: 'Not Found',
message: `Route ${pathname} not found`,
timestamp: new Date().toISOString()
}, null, 2));
}
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('Received SIGTERM, shutting down gracefully');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
console.log('Received SIGINT, shutting down gracefully');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
// Start server
server.listen(port, () => {
console.log(`🚀 Server running at http://localhost:${port}/`);
console.log(`Environment: ${environment}`);
console.log(`Node.js version: ${process.version}`);
});
// Export server for testing
module.exports = server;
What this code does:
- Creates an HTTP server that listens on port 3000
- Serves different endpoints (/, /health, /info, /metrics)
- Includes security headers and proper error handling
- Provides graceful shutdown capability
- Exports the server for testing
3.2 Install Dependencies
What this step does: Downloads and installs the necessary packages for testing and code quality.
Run the following commands:
# Install testing and development tools
npm install --save-dev jest eslint supertest
# Install all dependencies (creates node_modules folder)
npm install
What you’ll see:
A new
node_modules/
folder containing installed packagesA
package-lock.json
file that locks dependency versions for consistency across environments
Step 4: Create Proper Tests
🎯 What this step does
This step sets up automated testing for your application using Jest and Supertest. Automated tests help verify that your web app’s endpoints behave correctly and prevent regressions when making changes.
4.1 Create Tests Directory and File
First, create a folder to hold your tests and an initial test file:
#Create a folder for your tests
mkdir tests
# Create the main test file
touch tests/app.test.js
- Your project folder should look like this:
my-devops-project/
├── app.js
├── package.json
├── tests/
│ └── app.test.js
4.2 Write Your First Tests
Open tests/app.test.js
and add the following code:
const request = require('supertest');
const server = require('../app');
describe('App Endpoints', () => {
afterAll(() => {
server.close();
});
test('GET / should return welcome page', async () => {
const response = await request(server).get('/');
expect(response.status).toBe(200);
expect(response.text).toContain('DevOps Lab 2025');
});
test('GET /health should return health status', async () => {
const response = await request(server).get('/health');
expect(response.status).toBe(200);
expect(response.body.status).toBe('healthy');
expect(response.body.timestamp).toBeDefined();
expect(typeof response.body.uptime).toBe('number');
});
test('GET /info should return system info', async () => {
const response = await request(server).get('/info');
expect(response.status).toBe(200);
expect(response.body.platform).toBeDefined();
expect(response.body.node_version).toBeDefined();
});
test('GET /metrics should return prometheus metrics', async () => {
const response = await request(server).get('/metrics');
expect(response.status).toBe(200);
expect(response.text).toContain('http_requests_total');
expect(response.text).toContain('app_uptime_seconds');
});
test('GET /nonexistent should return 404', async () => {
const response = await request(server).get('/nonexistent');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Not Found');
});
});
✅ These tests check:
/ → Welcome page
/health → Health check info
/info → System details
/metrics → Prometheus metrics
/nonexistent → Correct 404 handling
4.3 Configure Jest
Next, set up a Jest configuration file so tests run consistently:
touch jest.config.js
Paste the following into jest.config.js:
module.exports = {
testEnvironment: 'node',
collectCoverage: true,
coverageDirectory: 'coverage',
testMatch: ['**/tests/**/*.test.js'],
verbose: true
};
Config breakdown:
testEnvironment: 'node' → Run in Node.js context
collectCoverage: true → Generate coverage reports
coverageDirectory: 'coverage' → Store reports in /coverage
testMatch → Look for files ending in .test.js inside /tests
verbose: true → Show detailed output
4.4 Run the Tests
Now run the tests with:
npm test
If everything is working, you’ll see output like:
Step 5: Start the App 🚀
🎯 What this step does
This step launches your Node.js web server so you can test it locally in your browser and confirm everything works before moving into Docker, CI/CD, and deployment in Part 2.
5.1 Start the Server
Run the following command in your project root:
npm start
You should see output like:
5.2 Verify Endpoints in Browser or curl
Now, open your browser (or use curl) to test the endpoints:
# Welcome page
curl http://localhost:3000/
# Health check
curl http://localhost:3000/health
# Info
curl http://localhost:3000/info
# Metrics
curl http://localhost:3000/metrics
✅ You should see the welcome HTML page at / and JSON responses at /health and /info. The /metrics endpoint will return Prometheus-style metrics.
5.3 Stop the Server
When you’re done testing, stop the server by pressing:
CTRL + C
🎉 Wrapping Up Part 1
In this first part of DevOps by Doing - Setting Up a Complete Modern DevOps Environment, we:
Set up Git for version control
Created a
Node.js
project with a sample web appInstalled dependencies and dev tools
Wrote and ran automated tests with
Jest
+Supertest
Started the app locally
You now have the foundation of a modern DevOps-ready application.
Coming Up in Part 2
In Part 2, we’ll take this project to the next level by:
Creating a
.gitignore
file to keep your repo cleanAdding a
.env.example
for environment variablesSetting up
ESLint
for code qualityWriting a
Dockerfile
and.dockerignore
Running your app with Docker Compose
Preparing for CI/CD with GitHub Actions
Stay tuned — things are about to get even more exciting! 🚀
Top comments (0)