Tools: a DevOps Pipeline from Scratch: Local Deployment with Vagrant, Docker & Nginx - Part 1 Building

Tools: a DevOps Pipeline from Scratch: Local Deployment with Vagrant, Docker & Nginx - Part 1 Building

Series Overview

What We're Building in Part 1

Here's the architecture:

Why This Stack

Step 1 : Create the Vagrantfile

Step 2 : Boot the VM

Step 3 : SSH into the VM

Step 4 : Get the Application Code

Step 5 : Add Nginx

Step 6: Update Docker Compose

Key design decisions in this config:

Step 7: Deploy the Application

Step 8: Verify the Deployment

Useful Commands

What's Coming in Part 2

Final Thoughts This is Part 1 of a series where I build a complete DevOps pipeline from the ground up; local deployment, CI/CD, security scanning, monitoring, and alerting. This series documents the end-to-end process of building a production-grade DevOps pipeline. Here's where we're headed: The Vagrantfile, Nginx config, and Docker Compose files used in this guide are available here: devops-pipeline-series The application code is sourced from Docker's official Repo In this first part, we deploy a full-stack web application locally using Vagrant as the infrastructure layer, Docker Compose to orchestrate the containers, and Nginx as the reverse proxy sitting in front of everything. All four services run as Docker containers on a shared private network. Nothing is exposed to the outside except Nginx on port 80. Vagrant lets us provision a real Ubuntu server locally. Everything practiced here transfers directly to cloud deployments. You can destroy and rebuild the environment as many times as needed , no cloud costs, no configuration drift etc. Docker Compose manages the multi-container setup through a single declarative file. Rather than running and networking containers manually, Docker compose handles the entire lifecycle with one command. Nginx as a reverse proxy is standard practice in production. It gives us with a single entry point for all traffic, cleanly separating routing concerns from application logic, and makes it straightforward to add SSL termination, rate limiting, or load balancing later. To verify installation, run this: Before we touch anything, make sure you're working on your local machine; not inside a VM or remote server.Open your terminal and follow along. Create a new directory for the Vagrant project and add a Vagrantfile In the vagrant-devops-project directory, create a vagrant file called Vagrantfile using this command touch Vagrantfile Open the Vagrantfile in a vim editor using this command vim Vagrantfile and paste the command below: When you're done pasting, press Esc to exit insert mode, then type :wq and hit Enter to save and close the file. Note:

The provision script uses Docker's official installer to install Docker and Docker Compose on the Ubuntu vm. The usermod line grants the vagrant user permission to run Docker without sudo. Port forwarding maps localhost:8080 on your machine to port 80 inside the VM, which is where Nginx listens. Run the command below to boot the VM. Run the command below to SSH into the VM. We're using Docker's official awesome-compose repository as the application base. The focus of this series is on infrastructure and pipeline , not the application code itself. Inside your VM, clone the project and cd into the awesome-compose/react-express-mysql using the code below: This is the project structure: The repository doesn't include Nginx. We add it ourselves.In the react-express-mysql directory, create an nginx directory using the command below: In the nginx directory, run this command vim nginx.conf and paste the code below: After pasting the code above, press Esc to exit insert mode, then type :wq and hit Enter to save and close the file. Note: frontend and backend _in the _proxy_pass directives are Docker container names, not folder references. When containers share a Docker network, they can resolve each other by service name. Docker handles the DNS internally , no IP addresses required. proxy_set_header forwards the original client information to the upstream service. This matters when your backend needs the real client IP for logging or rate limiting. Replace the contents of compose.yaml with this code below: Only _Nginx _has a ports mapping. Every other service communicates internally over app-network. This is intentional , the database and backend are never directly reachable from outside the VM. depends_on enforces startup ordering. Nginx won't start until the frontend and backend containers are up. The backend won't start until the database is ready. restart: always ensures containers recover automatically from crashes. The db-data named volume persists database state independently of the container lifecycle. Removing and recreating the database container does not wipe the data. Your updated project structure: Run these commands below to start docker compose Verify all containers are running: Open your browser on your local machine: The React application loads. The request path was: browser → Vagrant port forwarding → VM port 80 → Nginx → React frontend container. With the application running locally, the next step is automation.

In Part 2 we'll build a CI/CD pipeline using GitHub Actions that triggers on every push to the main branch. It will run automated tests, and if they pass, SSH into the server and deploy the latest version with zero manual intervention. After that, the series continues with: Trivy for scanning container images and source code for known vulnerabilities before they ever reach the server Centralized logging so you have visibility into what's happening across all containers Slack notifications for failed builds and deployments because the worst way to find out something broke is from a user I've been learning DevOps for a while now ; going through Docker, Linux, AWS, Vagrant, and currently working towards my Kubernetes certification. But there's a point where consuming content stops being enough and you just have to build something real. This project was that moment for me. And honestly, the thing I learned most wasn't about Docker or Nginx. It was that the tools make a lot more sense when they're solving a real problem in front of you. Why does Nginx sit in front of everything? Because you need one controlled entry point. Why do containers talk by name instead of IP? Because IPs change, names don't. Those things click differently when you're the one wiring them together. This setup is intentionally close to how production pipelines are structured .. a single entry point through Nginx, services isolated on a private network, infrastructure defined as code. Each part of this series builds directly on the previous one. By the end you'll have a complete pipeline, from a local vagrant up all the way through automated deployment, security scanning, and real-time alerting. Follow along and drop questions in the comments. Thanks. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

vagrant --version git --version vagrant --version git --version vagrant --version git --version mkdir ~/vagrant-devops-project cd ~/vagrant-devops-project mkdir ~/vagrant-devops-project cd ~/vagrant-devops-project mkdir ~/vagrant-devops-project cd ~/vagrant-devops-project Vagrant.configure("2") do |config| # Ubuntu 22.04 config.vm.box = "ubuntu/jammy64" config.vm.hostname = "devops-server" # Forward VM port 80 to localhost:8080 config.vm.network "forwarded_port", guest: 80, host: 8080 config.vm.provider "virtualbox" do |vb| vb.memory = "2048" vb.cpus = 2 end # Provision Docker using the official install script config.vm.provision "shell", inline: <<-SHELL curl -fsSL https://get.docker.com | sh usermod -aG docker vagrant SHELL end Vagrant.configure("2") do |config| # Ubuntu 22.04 config.vm.box = "ubuntu/jammy64" config.vm.hostname = "devops-server" # Forward VM port 80 to localhost:8080 config.vm.network "forwarded_port", guest: 80, host: 8080 config.vm.provider "virtualbox" do |vb| vb.memory = "2048" vb.cpus = 2 end # Provision Docker using the official install script config.vm.provision "shell", inline: <<-SHELL curl -fsSL https://get.docker.com | sh usermod -aG docker vagrant SHELL end Vagrant.configure("2") do |config| # Ubuntu 22.04 config.vm.box = "ubuntu/jammy64" config.vm.hostname = "devops-server" # Forward VM port 80 to localhost:8080 config.vm.network "forwarded_port", guest: 80, host: 8080 config.vm.provider "virtualbox" do |vb| vb.memory = "2048" vb.cpus = 2 end # Provision Docker using the official install script config.vm.provision "shell", inline: <<-SHELL curl -fsSL https://get.docker.com | sh usermod -aG docker vagrant SHELL end vagrant ssh vagrant ssh vagrant ssh git clone https://github.com/docker/awesome-compose.git cd awesome-compose/react-express-mysql git clone https://github.com/docker/awesome-compose.git cd awesome-compose/react-express-mysql git clone https://github.com/docker/awesome-compose.git cd awesome-compose/react-express-mysql react-express-mysql/ ├── frontend/ ← React application ├── backend/ ← Node.js / Express API ├── db/ ← Database init scripts ├── compose.yaml ← Docker Compose config └── README.md react-express-mysql/ ├── frontend/ ← React application ├── backend/ ← Node.js / Express API ├── db/ ← Database init scripts ├── compose.yaml ← Docker Compose config └── README.md react-express-mysql/ ├── frontend/ ← React application ├── backend/ ← Node.js / Express API ├── db/ ← Database init scripts ├── compose.yaml ← Docker Compose config └── README.md mkdir nginx cd nginx mkdir nginx cd nginx mkdir nginx cd nginx server { listen 80; # Route frontend traffic location / { proxy_pass http://frontend:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # Route API traffic to the backend location /api/ { proxy_pass http://backend:5000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } server { listen 80; # Route frontend traffic location / { proxy_pass http://frontend:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # Route API traffic to the backend location /api/ { proxy_pass http://backend:5000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } server { listen 80; # Route frontend traffic location / { proxy_pass http://frontend:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # Route API traffic to the backend location /api/ { proxy_pass http://backend:5000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } services: frontend: build: context: frontend target: development networks: - app-network depends_on: - backend backend: build: ./backend environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=password - DB_NAME=example networks: - app-network depends_on: - db restart: always db: image: mariadb:10.6.4-focal command: '--default-authentication-plugin=mysql_native_password' environment: - MYSQL_DATABASE=example - MYSQL_ROOT_PASSWORD=password volumes: - db-data:/var/lib/mysql networks: - app-network restart: always nginx: image: nginx:alpine ports: - "80:80" volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf depends_on: - frontend - backend networks: - app-network restart: always volumes: db-data: networks: app-network: driver: bridge services: frontend: build: context: frontend target: development networks: - app-network depends_on: - backend backend: build: ./backend environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=password - DB_NAME=example networks: - app-network depends_on: - db restart: always db: image: mariadb:10.6.4-focal command: '--default-authentication-plugin=mysql_native_password' environment: - MYSQL_DATABASE=example - MYSQL_ROOT_PASSWORD=password volumes: - db-data:/var/lib/mysql networks: - app-network restart: always nginx: image: nginx:alpine ports: - "80:80" volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf depends_on: - frontend - backend networks: - app-network restart: always volumes: db-data: networks: app-network: driver: bridge services: frontend: build: context: frontend target: development networks: - app-network depends_on: - backend backend: build: ./backend environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=password - DB_NAME=example networks: - app-network depends_on: - db restart: always db: image: mariadb:10.6.4-focal command: '--default-authentication-plugin=mysql_native_password' environment: - MYSQL_DATABASE=example - MYSQL_ROOT_PASSWORD=password volumes: - db-data:/var/lib/mysql networks: - app-network restart: always nginx: image: nginx:alpine ports: - "80:80" volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf depends_on: - frontend - backend networks: - app-network restart: always volumes: db-data: networks: app-network: driver: bridge react-express-mysql/ ├── frontend/ ├── backend/ ├── db/ ├── nginx/ │ └── nginx.conf ├── compose.yaml └── README.md react-express-mysql/ ├── frontend/ ├── backend/ ├── db/ ├── nginx/ │ └── nginx.conf ├── compose.yaml └── README.md react-express-mysql/ ├── frontend/ ├── backend/ ├── db/ ├── nginx/ │ └── nginx.conf ├── compose.yaml └── README.md docker compose up --build -d docker compose up --build -d docker compose up --build -d docker compose ps docker compose ps docker compose ps NAME STATUS react-express-mysql-frontend-1 running react-express-mysql-backend-1 running react-express-mysql-db-1 running react-express-mysql-nginx-1 running NAME STATUS react-express-mysql-frontend-1 running react-express-mysql-backend-1 running react-express-mysql-db-1 running react-express-mysql-nginx-1 running NAME STATUS react-express-mysql-frontend-1 running react-express-mysql-backend-1 running react-express-mysql-db-1 running react-express-mysql-nginx-1 running http://localhost:8080 http://localhost:8080 http://localhost:8080 # View logs for a specific service docker compose logs nginx docker compose logs backend # Stream logs in real time docker compose logs -f nginx # Open a shell inside a running container docker exec -it react-express-mysql-nginx-1 sh # Monitor container resource usage docker stats # Stop the stack docker compose down # Stop and remove all volumes (full reset) docker compose down -v # Restart a single service docker compose restart nginx # View logs for a specific service docker compose logs nginx docker compose logs backend # Stream logs in real time docker compose logs -f nginx # Open a shell inside a running container docker exec -it react-express-mysql-nginx-1 sh # Monitor container resource usage docker stats # Stop the stack docker compose down # Stop and remove all volumes (full reset) docker compose down -v # Restart a single service docker compose restart nginx # View logs for a specific service docker compose logs nginx docker compose logs backend # Stream logs in real time docker compose logs -f nginx # Open a shell inside a running container docker exec -it react-express-mysql-nginx-1 sh # Monitor container resource usage docker stats # Stop the stack docker compose down # Stop and remove all volumes (full reset) docker compose down -v # Restart a single service docker compose restart nginx - Part 1: Local Deployment with Vagrant, Docker, and Nginx - Part 2: CI/CD Pipeline with GitHub Actions (automated testing and deployment on every push) - Part 3: Security Scanning with Trivy (container image and code vulnerability scanning) - Part 4: Monitoring and Logging - Part 5: Slack Alerting for Failed Builds and Deployments (real-time notifications when things go wrong) - The Vagrantfile, Nginx config, and Docker Compose files used in this guide are available here: devops-pipeline-series - The application code is sourced from Docker's official Repo - Vagrant lets us provision a real Ubuntu server locally. Everything practiced here transfers directly to cloud deployments. You can destroy and rebuild the environment as many times as needed , no cloud costs, no configuration drift etc. - Docker Compose manages the multi-container setup through a single declarative file. Rather than running and networking containers manually, Docker compose handles the entire lifecycle with one command. - Nginx as a reverse proxy is standard practice in production. It gives us with a single entry point for all traffic, cleanly separating routing concerns from application logic, and makes it straightforward to add SSL termination, rate limiting, or load balancing later. - Vagrant 2.x - VirtualBox 7.x - Docker Engine (installed automatically) - Trivy for scanning container images and source code for known vulnerabilities before they ever reach the server - Centralized logging so you have visibility into what's happening across all containers - Slack notifications for failed builds and deployments because the worst way to find out something broke is from a user