homeresume
 
   
🔍

Nginx local setup

June 21, 2026

Nginx is a reverse proxy and web server. On a local machine it can sit on port 80 and route traffic to apps running on other ports - a frontend on :3000, an API on :4000, or a Docker Compose stack with published ports.

This post covers install, config layout, reverse proxying, path-based routes, static files, and service commands on a local dev machine. It does not cover HTTPS, production hardening, or multi-domain TLS.

On Windows, use WSL2 with Ubuntu. On macOS or Linux, the same apt or brew install steps apply.

Prerequisites

  • WSL2 with Ubuntu (Windows) or a Linux/macOS shell
  • At least one app listening on a local port (for example :3000)
  • sudo access to install packages and edit /etc/nginx

Mental model

  • server - a virtual host block (listen, server_name) that handles requests for a hostname.
  • location - a path prefix inside a server (/, /api/) matched against the request URI.
  • proxy_pass - forwards the request to another HTTP server (your Node app, Compose service, etc.).
  • root - serves files from a directory on disk (built SPA, static HTML).

On Debian and Ubuntu, site configs live in /etc/nginx/sites-available/ and are enabled with symlinks in /etc/nginx/sites-enabled/. The main file /etc/nginx/nginx.conf includes those enabled sites.

Install

sudo apt update
sudo apt install nginx

Start Nginx and verify the default page:

sudo systemctl start nginx
curl -I http://localhost

You should get an HTTP 200 or 301 response.

Config layout

PathPurpose
/etc/nginx/nginx.confMain config; includes enabled sites
/etc/nginx/sites-available/Site config files (one file per app or domain)
/etc/nginx/sites-enabled/Symlinks to enabled sites
/var/log/nginx/access.logRequest log
/var/log/nginx/error.logErrors and config issues

Create a new site file:

sudo nano /etc/nginx/sites-available/local-dev

Enable it and disable the default site if it conflicts on port 80:

sudo ln -s /etc/nginx/sites-available/local-dev /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

Always run nginx -t before reloading. A syntax error blocks reload and leaves the old config in place.

Proxy one service

Forward all traffic to an app on port 3000:

server {
listen 80;
server_name localhost;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

The Upgrade and Connection headers allow WebSocket connections through the proxy (useful for Vite, Next.js dev server, etc.).

Use an upstream block when several backend servers share the same target name; for a single local app, proxy_pass with a URL is enough.

Path-based routes

Route /api/ to a backend on port 4000 and everything else to a frontend on port 3000:

server {
listen 80;
server_name localhost;
location /api/ {
proxy_pass http://127.0.0.1:4000/;
proxy_set_header Host $host;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
}
}

Trailing slash in proxy_pass - proxy_pass http://127.0.0.1:4000/; (note the slash after the port) strips the matched /api/ prefix before forwarding. A request to /api/health reaches the backend as /health. Without the trailing slash, the backend receives /api/health.

Longer prefixes should use more specific location blocks. Nginx picks the best match for the request URI.

Static files

Serve a built frontend from disk instead of proxying:

server {
listen 80;
server_name localhost;
root /var/www/my-app;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

try_files falls back to index.html for client-side routes in SPAs.

Commands

CommandPurpose
sudo systemctl start nginxStart Nginx
sudo systemctl stop nginxStop Nginx
sudo systemctl restart nginxFull restart (drops active connections)
sudo systemctl reload nginxReload config without dropping connections
sudo systemctl status nginxCheck service state
sudo nginx -tValidate config syntax
sudo nginx -s reloadReload without systemd
curl -I http://localhostSmoke test from the shell

Safe edit workflow: edit config → sudo nginx -tsudo systemctl reload nginx.

Use reload after config changes when the syntax test passes. Use restart when Nginx fails to start or after package upgrades.

Local networking on Windows + WSL

Nginx runs inside WSL. 127.0.0.1 inside WSL refers to the Linux VM, not processes on the Windows host.

  • App runs inside WSL or Docker Compose - use http://127.0.0.1:<port> in proxy_pass when the port is published to the WSL host (Compose ports: mapping works).

  • App runs on Windows only - use the Windows host IP from WSL:

    grep nameserver /etc/resolv.conf | awk '{ print $2 }'

    Put that IP in proxy_pass instead of 127.0.0.1, or run the app inside WSL/Compose so localhost routing stays simple.

From a Windows browser, http://localhost reaches Nginx in WSL when it listens on port 80.

Troubleshooting

  • Port 80 already in use - check what holds the port: sudo ss -tlnp | grep :80. Stop the conflicting service or change Nginx listen to another port (for example 8080).
  • 502 Bad Gateway - the upstream app is not running or the proxy_pass URL is wrong. Confirm the app responds: curl http://127.0.0.1:3000.
  • Config test fails - read the path and line from sudo nginx -t output; fix the file and test again before reload.
  • Unexpected routing - check /var/log/nginx/error.log and /var/log/nginx/access.log.
  • Docker Compose overview - run frontend and API in Compose, then point Nginx at the published host ports

Demo

Runnable configs for this post live in the nginx-local-setup-demo folder. Get access via code demos.

Docker Compose overview

June 20, 2026

Docker Compose runs multi-container applications from a single YAML file. One command can start an API, a database, a message broker, and supporting tools for local development - without installing each service on the host machine.

This post covers Compose concepts and commands. For ready-made stacks, see the service-specific posts linked at the end (Postgres/Redis, RabbitMQ, MongoDB, Kafka, DynamoDB/SQS).

Prerequisites

  • Docker Engine installed
  • Compose V2 - use docker compose (with a space). Current Docker Desktop includes it; no separate Compose install is required.

Mental model

  • Project - the folder that contains docker-compose.yml. The project name defaults to the directory name and prefixes container names.
  • Services - named containers defined in the file (api, redis, postgres). Each service maps to one image or build context.
  • Network - Compose creates a default network so services resolve each other by name. From the api container, Redis is reachable at redis:6379, not localhost:6379.
  • Volumes - named or bind mounts for data that survives docker compose down (unless you pass -v).

Minimal compose file

A two-service stack: a Node API and Redis. No top-level version: key - it is deprecated in the current Compose specification.

services:
api:
build: .
ports:
- 3000:3000
environment:
REDIS_URL: redis://redis:6379
depends_on:
- redis
redis:
image: redis:alpine
volumes:
- redis-data:/data
volumes:
redis-data:

Run docker compose up --build from the directory that contains this file.

Core concepts

Ports - map host ports to container ports as host:container:

ports:
- 3000:3000

Environment - inline variables or an env file:

environment:
REDIS_URL: redis://redis:6379
env_file:
- .env

Volumes - named volumes are managed by Docker (good for database data). Bind mounts map a host path into the container (good for live code reload during development):

volumes:
- redis-data:/data # named
- ./src:/app/src:ro # bind mount

Networks - services on the default network can reach each other by service name. Custom networks isolate groups of services (see the Postgres and Redis post for a multi-network example).

depends_on - controls startup order. It does not wait for the dependency to be ready; add a healthcheck or retry logic in the app when you need readiness.

restart - policies like on-failure:3 or unless-stopped keep containers running after crashes or host reboots.

healthcheck - optional probe so Compose and other services know when a container is ready:

healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 5

Commands

CommandPurpose
docker compose upStart services (foreground, logs in terminal)
docker compose up -dStart in detached mode
docker compose up --buildRebuild images before starting
docker compose downStop and remove containers
docker compose down -vAlso remove named volumes
docker compose psList running services
docker compose logs -f apiFollow logs for one service
docker compose exec api shOpen a shell in a running container
docker compose pullPull latest images

When to use what

ToolBest for
docker runOne-off containers, quick image tests
Docker ComposeLocal multi-service stacks, dev databases and queues
NginxLocal reverse proxy on port 80 in front of Compose or dev servers - see Nginx local setup
KubernetesProduction orchestration, scaling, rolling deploys

Service-specific setups

Demo

Runnable files for this post live in the docker-compose-overview-demo folder. Get access via code demos.

Infrastructure as Code (IaC) with AWS CDK (EC2 Example)

June 11, 2026

Infrastructure as Code (IaC) is a DevOps approach where infrastructure is defined and managed using code instead of manual setup. This makes environments reproducible, version-controlled, and easy to scale.

In this guide, you'll provision an AWS EC2 instance using the AWS Cloud Development Kit (CDK). CDK lets you define infrastructure in TypeScript (or other languages), synthesize CloudFormation templates, and deploy stacks through the CDK CLI.

Requirements

Before starting, install:

  • Node.js version 26
  • AWS CDK CLI (npm i -g aws-cdk)
  • AWS CLI

AWS Credentials Setup

  1. Go to IAM → Security credentials in AWS
  2. Create access keys
  3. Configure locally:
aws configure

This stores credentials in:

  • ~/.aws/credentials
  • ~/.aws/config

CDK requires CloudFormation permissions (and bootstrap-related IAM/S3 access). An IAM user scoped only for Terraform EC2 is often insufficient - attach broader permissions or use a dedicated CDK user.

Project Structure

A simple CDK TypeScript setup:

.
├── bin/
│ └── app.ts
├── lib/
│ └── ec2-stack.ts
├── cdk.json
├── package.json
└── tsconfig.json

Install dependencies:

npm i

App Entry

The CDK app wires stacks together in bin/app.ts:

#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { Ec2Stack } from '../lib/ec2-stack';
const app = new cdk.App();
new Ec2Stack(app, 'Ec2Stack', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? 'eu-north-1',
},
instanceName: app.node.tryGetContext('instanceName') ?? 'MyEC2Name',
});

Set the target account and region once per shell session:

export CDK_DEFAULT_ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
export CDK_DEFAULT_REGION=eu-north-1

On Windows PowerShell:

$env:CDK_DEFAULT_ACCOUNT = aws sts get-caller-identity --query Account --output text
$env:CDK_DEFAULT_REGION = "eu-north-1"

Stack and EC2 Instance

Define the stack in lib/ec2-stack.ts:

import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';
export interface Ec2StackProps extends cdk.StackProps {
instanceName?: string;
}
export class Ec2Stack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: Ec2StackProps) {
super(scope, id, props);
const instanceName = props?.instanceName ?? 'MyEC2Name';
const instance = new ec2.Instance(this, 'AppServer', {
vpc: ec2.Vpc.fromLookup(this, 'DefaultVpc', { isDefault: true }),
instanceType: ec2.InstanceType.of(
ec2.InstanceClass.T3,
ec2.InstanceSize.MICRO,
),
machineImage: ec2.MachineImage.latestAmazonLinux2023(),
instanceName,
});
new cdk.CfnOutput(this, 'InstanceId', {
value: instance.instanceId,
description: 'EC2 instance ID',
});
new cdk.CfnOutput(this, 'InstancePublicIp', {
value: instance.instancePublicIp,
description: 'Public IP address',
});
}
}

CDK resolves the default VPC during synthesis. Amazon Linux 2023 is selected automatically for the current region.

Configuration

CDK settings live in cdk.json:

{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"context": {
"instanceName": "MyEC2Name"
}
}

Change instanceName in context to override the default tag without editing stack code.

CDK Workflow

Bootstrap the environment (once per account/region):

  • cdk bootstrap

Preview the CloudFormation template:

  • cdk synth

Compare deployed stack with local changes:

  • cdk diff

Deploy the stack:

  • cdk deploy

Destroy infrastructure:

  • cdk destroy

Important Notes

  • State management

CDK deploys through CloudFormation. Stack state lives in AWS, not in a local file like Terraform's terraform.tfstate.

  • Bootstrap stack

The first cdk bootstrap creates an S3 bucket and IAM roles CDK uses for deployments. Keep the bootstrap stack in place while you use CDK in that account/region.

  • Idempotency

Running cdk deploy again updates the stack in place when possible instead of recreating resources unnecessarily.

  • Version control

Treat CDK code like application code. Commit cdk.json and lockfiles; do not commit cdk.out/ or node_modules/.

Demo

CDK project files for this post live in the cdk-aws-ec2 folder. Get access via code demos.

Infrastructure as Code (IaC) with Terraform (AWS EC2 Example)

April 28, 2026

Infrastructure as Code (IaC) is a DevOps approach where infrastructure is defined and managed using code instead of manual setup. This makes environments reproducible, version-controlled, and easy to scale.

In this guide, you'll provision an AWS EC2 instance using Terraform.

Requirements

Before starting, install:

  • Terraform
  • AWS CLI

AWS Credentials Setup

  1. Go to IAM → Security credentials in AWS
  2. Create access keys
  3. Configure locally:
aws configure

This stores credentials in:

  • ~/.aws/credentials
  • ~/.aws/config

Project Structure

A simple Terraform setup:

.
├── main.tf
├── variables.tf
├── terraform.tfvars
├── outputs.tf

Provider Configuration

Define your cloud provider in main.tf:

provider "aws" {
profile = "default"
region = "eu-north-1"
}

Variables

Define reusable variables in variables.tf:

variable "instance_name" {
description = "Name tag for EC2 instance"
type = string
default = "MyNewInstance"
}
variable "ec2_instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}

Set values in terraform.tfvars:

instance_name = "MyEC2Name"
ec2_instance_type = "t3.micro"

EC2 Instance Configuration

Add the resource in main.tf:

resource "aws_instance" "app_server" {
ami = "ami-077d1b9f9a1902bbc"
instance_type = var.ec2_instance_type
tags = {
Name = var.instance_name
}
}

You can find AMI IDs in EC2 → Images → AMI Catalog.

Outputs

Expose useful data in outputs.tf:

output "instance_id" {
description = "EC2 instance ID"
value = aws_instance.app_server.id
}
output "instance_public_ip" {
description = "Public IP address"
value = aws_instance.app_server.public_ip
}

Terraform Workflow

Initialize the project:

  • terraform init

Preview changes:

  • terraform plan

Apply changes:

  • terraform apply

Destroy infrastructure:

  • terraform destroy

Important Notes

  • State file

Terraform stores infrastructure state in terraform.tfstate.

Do not commit this file to Git.

  • Remote state (recommended)

For teams, store state in S3 with locking.

  • Idempotency

Running apply multiple times won't recreate resources unnecessarily.

  • Version control

Treat Terraform code like application code.

Demo

Terraform files for this post live in the terraform-aws-ec2 folder. Get access via code demos.