DevOps Basics

All 18 notes on one page

Networking Fundamentals

1

How DNS Works

beginner dns networking domain-names

Every time we type something like google.com into a browser, the browser has no idea what that means. Computers only understand IP addresses — numbers like 142.250.190.14. So something needs to translate that human-friendly name into an IP address. That something is DNS — the Domain Name System.

Think of DNS as the phonebook of the internet. We know a person’s name, but we need their phone number to call them. DNS does exactly that — we give it a domain name, it gives us back an IP address.

The DNS Resolution Flow

When we hit example.com in the browser, here’s what actually happens behind the scenes:

Browser Cache
visited before?
miss →
OS Cache
/etc/hosts or system DNS cache
miss →
Recursive Resolver
ISP or 8.8.8.8 / 1.1.1.1
miss →
Root Server
13 root servers worldwide
→ "ask .com TLD"
TLD Server
.com, .org, .io, etc.
→ "ask authoritative NS"
Authoritative NS
has the actual IP address
→ 142.250.190.14

The resolver walks down this chain, and once it gets the answer, it caches it so future lookups are instant.

DNS Record Types

DNS doesn’t just store IP addresses. There are several record types, each serving a different purpose:

  • A — Maps a domain to an IPv4 address (example.com → 93.184.216.34)
  • AAAA — Maps a domain to an IPv6 address (example.com → 2606:2800:220:1:...)
  • CNAME — Alias for another domain (www.example.com → example.com). It’s like a redirect at the DNS level.
  • MX — Mail exchange. Points to the mail server for a domain (example.com → mail.example.com). This is how email knows where to go.
  • TXT — Text records used for verification, SPF (email spam prevention), and domain ownership proof.
  • NS — Nameserver records. Tells us which DNS servers are authoritative for a domain.

TTL and DNS Caching

Every DNS record comes with a TTL (Time to Live) — a number in seconds that tells resolvers how long to cache the record. A TTL of 3600 means “cache this for 1 hour.”

This is why DNS changes don’t take effect immediately. When we update a record, old cached copies stick around until their TTL expires. This is called DNS propagation and it can take anywhere from minutes to 48 hours depending on the TTL values involved.

# Check the TTL of a domain's A record
dig example.com A

# Output includes something like:
# example.com.    3600    IN    A    93.184.216.34
#                 ^^^^
#                 TTL in seconds (1 hour)

Practical Commands

Two essential tools for debugging DNS: dig and nslookup.

# dig — the go-to DNS debugging tool
dig pman47.cc                    # look up A record
dig pman47.cc MX                 # look up mail records
dig pman47.cc +short             # just show the IP, no extra info
dig @8.8.8.8 pman47.cc          # query Google's DNS specifically

# nslookup — simpler, works on all platforms
nslookup pman47.cc               # basic lookup
nslookup -type=MX pman47.cc     # look up mail records

dig gives us way more detail (TTL, authoritative server, query time), while nslookup is quicker for a fast check. On macOS and Linux, dig is usually pre-installed. On Windows, nslookup is the default.

Why DNS Matters for Deployments

When we deploy a new site or change hosting providers, we need to update DNS records to point to the new server. A few things to keep in mind:

  • Lower the TTL first. Before a migration, drop the TTL to something like 60 seconds a day in advance. That way, when we flip the DNS record, the old IP won’t be cached for hours.
  • DNS propagation delays mean not everyone sees the change at the same time. Some ISPs are faster than others.
  • Always verify with dig after making changes to confirm the records have propagated.
# After updating DNS, check propagation
dig pman47.cc +short
# Should show the new IP address

# Check from a specific DNS resolver to compare
dig @1.1.1.1 pman47.cc +short   # Cloudflare's resolver
dig @8.8.8.8 pman47.cc +short   # Google's resolver

In simple language, DNS translates domain names into IP addresses by walking a chain of servers — and caching makes it fast, but also means changes take time to propagate.


2

Networking Basics for Devs

beginner networking tcp udp ip-address ports

We don’t need to be network engineers, but understanding the basics of how data moves across the internet is essential. Every time we deploy an app, debug a connection issue, or configure a firewall, we’re dealing with networking concepts.

IP Addresses

An IP address is like a street address for a device on a network. There are two versions:

IPv4 — The classic format. Four numbers separated by dots, each between 0 and 255. Example: 192.168.1.10. There are about 4.3 billion possible addresses, and we’ve basically run out.

IPv6 — The newer format. Eight groups of hexadecimal digits separated by colons. Example: 2001:0db8:85a3:0000:0000:8a2e:0370:7334. There are enough IPv6 addresses for every grain of sand on Earth to have billions of them.

Private vs Public IP Ranges

Not all IPs are routable on the public internet. Some ranges are reserved for private networks (like our home Wi-Fi or office LAN):

  • 10.0.0.0 to 10.255.255.255 — large networks, often used in cloud/VPC setups
  • 172.16.0.0 to 172.31.255.255 — medium networks
  • 192.168.0.0 to 192.168.255.255 — home routers, the one we see most often

Our router gets one public IP from the ISP and hands out private IPs to all our devices. This is called NAT (Network Address Translation).

Ports

If an IP address is the street address of a building, a port is the apartment number. A single server can run multiple services, and each one listens on a different port.

Ports range from 0 to 65535. Here are the ones we’ll run into constantly:

PortServiceNotes
22SSHRemote access to servers
80HTTPUnencrypted web traffic
443HTTPSEncrypted web traffic
3000Dev serversExpress, Next.js, Vite defaults
5432PostgreSQLDefault Postgres port
6379RedisDefault Redis port
8080Alt HTTPCommon alternative for HTTP
27017MongoDBDefault Mongo port
# Check what's listening on a specific port
lsof -i :3000          # macOS/Linux — who's using port 3000?
netstat -tlnp          # Linux — list all listening ports

# Kill a process on a specific port (macOS)
lsof -ti :3000 | xargs kill -9

TCP vs UDP

These are the two main transport protocols. They sit on top of IP and determine how data is delivered.

TCP (Transmission Control Protocol) — Reliable and ordered. It sets up a connection first (the “three-way handshake”), makes sure every packet arrives, and reassembles them in order. If a packet is lost, it retransmits it. This is what HTTP, SSH, email, and database connections use.

UDP (User Datagram Protocol) — Fast but unreliable. No connection setup, no guarantee packets arrive, no ordering. Just fire and forget. This is what video calls, online gaming, DNS queries, and live streaming use — situations where speed matters more than perfection.

TCP: "Hey, are you there?" → "Yes" → "OK, sending data" → data → "Got it?" → "Yes"
UDP: data → data → data → data (hope you got it!)

What Happens When We Type a URL

This is the classic interview question. Let’s walk through what actually happens when we type https://pman47.cc and hit Enter:

1.
DNS Lookup
pman47.cc → 144.24.126.230
2.
TCP Handshake
SYN → SYN-ACK → ACK (3-way)
3.
TLS Handshake
negotiate encryption (HTTPS only)
4.
HTTP Request
GET / HTTP/1.1
5.
Server Response
200 OK + HTML content
6.
Browser Renders
parse HTML → fetch CSS/JS → paint page

All of this happens in milliseconds. The browser also fetches additional resources (CSS, JS, images) as it parses the HTML, often in parallel.

localhost, 0.0.0.0, and 127.0.0.1

These three come up all the time in development and they’re subtly different:

  • 127.0.0.1 — The loopback address. Traffic sent here never leaves the machine. It always refers to “this computer.”
  • localhost — A hostname that typically resolves to 127.0.0.1 (configured in /etc/hosts). On some systems it might resolve to the IPv6 loopback ::1 instead.
  • 0.0.0.0 — Means “all network interfaces.” When a server binds to 0.0.0.0, it listens on every available interface — loopback, Wi-Fi, Ethernet, everything. This is important in Docker, where binding to 127.0.0.1 means the container can’t be reached from outside.
# This only accepts connections from the same machine
node server.js --host 127.0.0.1

# This accepts connections from anywhere (needed in Docker)
node server.js --host 0.0.0.0

# Check what a hostname resolves to
getent hosts localhost
# 127.0.0.1       localhost

A common Docker gotcha: our app starts fine but we can’t reach it from the host machine. Nine times out of ten, it’s because the app is bound to 127.0.0.1 instead of 0.0.0.0.

In simple language, data travels across the internet using IP addresses to find the right machine and ports to find the right service — with TCP making sure nothing gets lost along the way.


3

SSL, TLS & HTTPS

intermediate ssl tls https encryption certificates

When we browse a site over plain HTTP, everything we send and receive — passwords, credit card numbers, personal data — travels in plain text. Anyone sitting on the same Wi-Fi, or any server between us and the destination, can read it all. That’s terrifying.

HTTPS fixes this by encrypting the traffic between our browser and the server. The “S” stands for Secure, and it uses a protocol called TLS (Transport Layer Security) to make it happen.

HTTP vs HTTPS

The difference is simple:

  • HTTP — Data travels in plain text. Anyone can intercept and read it.
  • HTTPS — Data is encrypted. Even if someone intercepts it, they see gibberish.

HTTPS also provides authentication (we know we’re talking to the real server, not an impersonator) and integrity (the data hasn’t been tampered with in transit).

SSL vs TLS — What’s the Difference?

SSL (Secure Sockets Layer) is the old protocol. It was replaced by TLS back in 1999. When people say “SSL certificate” today, they almost always mean a TLS certificate. SSL is technically dead — every version has known vulnerabilities. We use TLS 1.2 and TLS 1.3 in the real world.

But the name “SSL” stuck around in everyday language. “SSL certificate,” “SSL termination” — it all refers to TLS under the hood.

The TLS Handshake — Simplified

Before any encrypted data flows, the browser and server need to agree on how to encrypt. This negotiation is called the TLS handshake. Here’s the simplified version:

Browser (Client)
Server
1. Client Hello ──────→ "Here are the TLS versions and ciphers I support"
2. "Here's my certificate + chosen cipher" ←────── Server Hello + Cert
3. Verify Certificate browser checks with CA
4. Key Exchange ←────→ both derive shared secret key
5. Encrypted Connection Established ←═══→ all data is now encrypted

TLS 1.3 made this faster — it takes just 1 round-trip instead of 2. There’s even a “0-RTT” mode for repeat connections where the handshake is nearly instant.

Certificates and Certificate Authorities

A certificate is basically the server saying “I am who I claim to be.” But anyone can create a certificate and claim to be google.com. That’s where Certificate Authorities (CAs) come in.

A CA is a trusted third party that verifies domain ownership and signs certificates. Our browsers come pre-loaded with a list of trusted CAs. When the browser gets a certificate, it checks: “Was this signed by a CA I trust?” If yes, we get the padlock icon. If not, we get a scary warning page.

Let’s Encrypt

Before Let’s Encrypt, SSL certificates cost money and were a hassle to set up. Let’s Encrypt changed everything — it’s a free, automated CA. Tools like Certbot and Caddy can automatically obtain and renew certificates from Let’s Encrypt.

# Using Certbot to get a free certificate
sudo certbot --nginx -d example.com -d www.example.com

# Caddy does it automatically — just configure the domain
# Caddyfile:
# example.com {
#     reverse_proxy localhost:3000
# }
# That's it. Caddy handles TLS automatically.

Self-Signed Certificates in Development

In development, we sometimes need HTTPS but don’t want to deal with a real CA. We can create a self-signed certificate — one we sign ourselves.

# Generate a self-signed cert for local development
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
  -sha256 -days 365 -nodes \
  -subj "/CN=localhost"

# Use it with a Node.js server
# const https = require('https');
# const fs = require('fs');
# https.createServer({
#   key: fs.readFileSync('key.pem'),
#   cert: fs.readFileSync('cert.pem')
# }, app).listen(3000);

The browser will still show a warning because it doesn’t trust our homemade CA. We can either click through the warning or add the cert to our system’s trust store.

For local development, tools like mkcert make this much easier — they create a local CA and add it to the system trust store automatically.

Why Mixed Content Gets Blocked

If an HTTPS page loads a resource (image, script, stylesheet) over plain HTTP, the browser blocks it or shows a warning. This is called mixed content.

Why? Because that one insecure resource creates a hole in the encryption. An attacker could modify the HTTP resource in transit, injecting malicious code into an otherwise secure page. The browser protects us by refusing to load it.

https://example.com/page.html      ← secure
  └── loads http://cdn.com/app.js  ← BLOCKED (mixed content)
  └── loads https://cdn.com/app.js ← allowed

The fix is simple: make sure every resource on an HTTPS page is also loaded over HTTPS. Use //cdn.com/resource.js (protocol-relative) or just always use https://.

Encryption In Transit vs At Rest

TLS encrypts data in transit — while it’s moving between our browser and the server. Once the data arrives at the server, TLS’s job is done. The data sits on the server’s disk in whatever format the application stores it.

Encryption at rest is a separate concern — that’s about encrypting data on disk (using things like full-disk encryption, encrypted database columns, or encrypted S3 buckets). A secure system uses both.

In simple language, HTTPS wraps our HTTP traffic in a layer of encryption using TLS, so nobody between us and the server can read or tamper with the data.

References


4

SSH Basics

beginner ssh remote-access security keys

SSH (Secure Shell) is how we remotely access servers. Instead of physically sitting at a machine, we open a terminal, type ssh user@server, and we’re in. The entire session is encrypted — unlike the old days of Telnet where everything went over the wire in plain text.

Every backend developer, DevOps engineer, and anyone who deploys code to a server uses SSH daily. It’s one of those tools we absolutely need to know.

Password vs Key-Based Auth

When we SSH into a server, we need to prove who we are. There are two ways:

Password authentication — The server asks for a password, we type it in. Simple, but not great:

  • Passwords can be brute-forced
  • We have to type them every time
  • If we manage 10 servers, that’s 10 passwords to remember

Key-based authentication — We generate a key pair (private + public). We put the public key on the server. When we connect, SSH uses math to verify we have the matching private key — without ever sending the key over the network. This is better because:

  • No password to brute-force
  • No password to type (or remember)
  • The private key never leaves our machine

Generating SSH Keys

The modern standard is Ed25519 — it’s fast, secure, and produces short keys.

# Generate a new SSH key pair
ssh-keygen -t ed25519 -C "manish@pman47.cc"

# It will ask:
# - Where to save (default: ~/.ssh/id_ed25519) — just hit Enter
# - Passphrase — optional but recommended (encrypts the key on disk)

# This creates two files:
# ~/.ssh/id_ed25519       ← PRIVATE key (never share this)
# ~/.ssh/id_ed25519.pub   ← PUBLIC key (put this on servers)

The private key is like our house key — we never give it to anyone. The public key is like the lock — we install it on every server we want to access.

# Copy the public key to a server
ssh-copy-id user@server-ip
# This adds our public key to ~/.ssh/authorized_keys on the server

# Or manually copy it
cat ~/.ssh/id_ed25519.pub
# Copy the output and paste it into ~/.ssh/authorized_keys on the server

After this, we can SSH in without a password:

ssh user@server-ip
# No password prompt — key auth just works

The SSH Config File

Typing ssh ubuntu@144.24.126.230 -i ~/.ssh/oracle_key -p 2222 every time is painful. The SSH config file at ~/.ssh/config lets us create shortcuts.

# ~/.ssh/config

# Oracle Cloud VPS
Host oracle
    HostName 144.24.126.230
    User ubuntu
    IdentityFile ~/.ssh/oracle_ed25519
    Port 22

# GitHub
Host github.com
    HostName github.com
    User git
    IdentityFile ~/.ssh/github_ed25519

# Work server with jump host
Host work-prod
    HostName 10.0.1.50
    User deploy
    IdentityFile ~/.ssh/work_key
    ProxyJump bastion.work.com

Now instead of that long command, we just type:

ssh oracle            # connects to the VPS
ssh work-prod         # connects through the jump host automatically
git clone git@github.com:pman47/gyaan.git   # uses the right key

SSH Keys for Git

GitHub and GitLab support SSH key authentication for pushing and pulling repos. Once we add our public key to our GitHub account, we never need to type passwords for Git operations.

# Add SSH key to the ssh-agent (so we don't type the passphrase every time)
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/github_ed25519

# Test the connection
ssh -T git@github.com
# Hi pman47! You've successfully authenticated, but GitHub
# does not provide shell access.

# Clone using SSH (not HTTPS)
git clone git@github.com:pman47/gyaan.git

The SSH URL format is git@github.com:user/repo.git. If we’re still using HTTPS URLs (https://github.com/...), we can switch:

# Switch a repo from HTTPS to SSH
git remote set-url origin git@github.com:pman47/gyaan.git

Port Forwarding / Tunneling

SSH isn’t just for remote terminals. We can also use it to create encrypted tunnels — forwarding a port on our local machine to a port on a remote machine (or vice versa).

Local port forwarding — Access a remote service as if it were local:

# Forward local port 5432 to the remote server's PostgreSQL
ssh -L 5432:localhost:5432 ubuntu@oracle

# Now we can connect to the remote Postgres from our local machine:
# psql -h localhost -p 5432 -U postgres

This is incredibly useful when a database or service is behind a firewall and only accessible from the server. We create an SSH tunnel and access it through localhost.

The -N flag runs SSH without opening a shell — useful when we only need the tunnel:

# Just create the tunnel, no interactive shell
ssh -L 5432:localhost:5432 -N ubuntu@oracle

Common SSH Troubleshooting

When SSH doesn’t work, here are the usual suspects:

# Permission issues — SSH is strict about file permissions
chmod 700 ~/.ssh              # directory must be 700
chmod 600 ~/.ssh/id_ed25519   # private key must be 600
chmod 644 ~/.ssh/id_ed25519.pub  # public key can be 644
chmod 600 ~/.ssh/config       # config file must be 600

# Debug connection issues with verbose mode
ssh -v user@server    # shows what's happening step by step
ssh -vvv user@server  # maximum verbosity for stubborn issues

If we see “Permission denied (publickey)”, it usually means:

  • The public key isn’t in ~/.ssh/authorized_keys on the server
  • File permissions are wrong (SSH refuses keys with loose permissions)
  • We’re using the wrong key (check IdentityFile in config)

In simple language, SSH gives us encrypted remote access to servers, and key-based auth with a config file makes connecting to multiple servers effortless and secure.


HTTP & Web Protocols

5

HTTP Methods, Status Codes & Headers

beginner http status-codes methods headers rest

HTTP (HyperText Transfer Protocol) is the foundation of how the web works. Every time we load a page, submit a form, or call an API, we’re using HTTP. It’s a request-response protocol — the client sends a request, the server sends back a response.

Anatomy of an HTTP Request

Every HTTP request has these parts:

GET /api/users?page=1 HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer eyJhbGciOi...
  • Method — What we want to do (GET, POST, etc.)
  • URL/Path — The resource we’re targeting (/api/users?page=1)
  • Headers — Metadata about the request (who we are, what we accept, auth tokens)
  • Body — Optional data payload (for POST, PUT, PATCH)

And every HTTP response looks like this:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=3600

{"users": [{"id": 1, "name": "Manish"}]}
  • Status code — Did it work? (200, 404, 500, etc.)
  • Headers — Metadata about the response
  • Body — The actual data

HTTP Methods

These tell the server what operation we want to perform:

GET — Retrieve data. Should never modify anything on the server. This is what happens when we load a page or fetch data from an API.

POST — Create a new resource. The data goes in the request body. Submitting a form, creating a user, uploading a file.

PUT — Replace a resource completely. We send the entire updated object. If the resource doesn’t exist, some APIs create it.

PATCH — Partially update a resource. We only send the fields that changed. More efficient than PUT when we’re only changing one field.

DELETE — Remove a resource.

# GET — fetch all users
curl https://api.example.com/users

# POST — create a new user
curl -X POST https://api.example.com/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Manish", "email": "hi@pman47.cc"}'

# PATCH — update just the email
curl -X PATCH https://api.example.com/users/1 \
  -H "Content-Type: application/json" \
  -d '{"email": "new@pman47.cc"}'

# DELETE — remove a user
curl -X DELETE https://api.example.com/users/1

Idempotency and Safe Methods

Two important concepts that come up in interviews:

Safe methods — Don’t modify anything on the server. GET and HEAD are safe. Calling them 100 times has the same effect as calling them 0 times.

Idempotent methods — Calling them once has the same effect as calling them multiple times. GET, PUT, DELETE are idempotent. If we send the same PUT request 5 times, the result is the same as sending it once.

POST is neither safe nor idempotent. Sending the same POST twice might create two resources. That’s why we see “Don’t click submit twice” warnings on payment forms.

Status Codes

Status codes are grouped by their first digit:

1xx — Informational

Rarely seen directly. 100 Continue means “keep sending the request body.”

2xx — Success

  • 200 OK — The request worked. The most common one.
  • 201 Created — A new resource was created (common after POST).
  • 204 No Content — Success, but nothing to send back (common after DELETE).

3xx — Redirection

  • 301 Moved Permanently — The resource has a new URL forever. Search engines update their index.
  • 302 Found — Temporary redirect. The old URL is still valid.
  • 304 Not Modified — The cached version is still good, no need to download again.

4xx — Client Error (our fault)

  • 400 Bad Request — The server can’t understand what we sent (malformed JSON, missing fields).
  • 401 Unauthorized — We’re not authenticated. “Who are you? Show me your ID.”
  • 403 Forbidden — We’re authenticated but don’t have permission. “I know who you are, but you can’t do that.”
  • 404 Not Found — The resource doesn’t exist.
  • 409 Conflict — The request conflicts with the current state (e.g., duplicate email on signup).
  • 429 Too Many Requests — We’re being rate-limited. Slow down.

5xx — Server Error (their fault)

  • 500 Internal Server Error — Something broke on the server. The catch-all error.
  • 502 Bad Gateway — The server (acting as a proxy) got an invalid response from the upstream server. Common with Nginx when the app behind it crashes.
  • 503 Service Unavailable — The server is overloaded or down for maintenance.

Common Headers

Headers carry metadata. Here are the ones we deal with most:

Request headers:

  • Content-Type: application/json — Tells the server what format the body is in
  • Accept: application/json — Tells the server what format we want back
  • Authorization: Bearer <token> — Authentication token (JWT, API key)
  • Cookie: session=abc123 — Sends cookies to the server

Response headers:

  • Content-Type: application/json — Format of the response body
  • Set-Cookie: session=abc123; HttpOnly; Secure — Tells the browser to store a cookie
  • Cache-Control: max-age=3600 — How long the browser can cache this response
  • X-RateLimit-Remaining: 42 — How many API calls we have left
# See all response headers with curl
curl -I https://api.github.com
# -I sends a HEAD request (headers only, no body)

# See request AND response headers
curl -v https://api.github.com 2>&1 | head -30
# Lines starting with > are request headers
# Lines starting with < are response headers

A Real curl Example

Here’s a complete request-response cycle we can actually run:

# Make a POST request and see the full exchange
curl -v -X POST https://httpbin.org/post \
  -H "Content-Type: application/json" \
  -H "X-Custom-Header: hello" \
  -d '{"name": "Manish"}'

# Response includes:
# HTTP/2 200
# content-type: application/json
# {
#   "headers": { "Content-Type": "application/json", "X-Custom-Header": "hello" },
#   "json": { "name": "Manish" },
#   "url": "https://httpbin.org/post"
# }

httpbin.org is great for testing — it echoes back whatever we send, so we can see exactly how our request looks from the server’s perspective.

In simple language, HTTP is a request-response protocol where the method says what we want to do, the status code says what happened, and headers carry the metadata that makes it all work together.

References


6

CORS

intermediate cors security browser same-origin-policy

At some point, every web developer has seen this error in the browser console:

Access to fetch at 'https://api.example.com/data' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

It’s frustrating the first time, but once we understand why it exists and how it works, CORS becomes straightforward.

Same-Origin Policy

The browser has a security mechanism called the Same-Origin Policy. It says: “JavaScript on page A can only make requests to page A’s origin.” An origin is the combination of protocol + domain + port.

https://example.com:443  ← this is one origin
  ↓        ↓        ↓
protocol  domain    port

These are considered different origins:

  • http://example.com and https://example.com — different protocol
  • https://example.com and https://api.example.com — different subdomain
  • http://localhost:3000 and http://localhost:8080 — different port

Why does the browser do this? Imagine we’re logged into our bank at bank.com. If a malicious page at evil.com could freely make requests to bank.com using our cookies, it could transfer our money. The Same-Origin Policy prevents this.

What CORS Actually Is

CORS (Cross-Origin Resource Sharing) is the mechanism that lets servers opt in to allowing cross-origin requests. It’s not a security wall — it’s a door with a lock that the server controls.

When our frontend at localhost:3000 makes a request to our API at localhost:8080, the browser asks the server: “Hey, is localhost:3000 allowed to talk to you?” If the server says yes (through CORS headers), the browser allows it. If not, the browser blocks the response.

Important: CORS is enforced by the browser, not the server. The server sends the response either way. The browser just refuses to let our JavaScript see it if the CORS headers are missing. This is why the same request works fine from curl or Postman — they don’t enforce CORS.

Simple Requests vs Preflight Requests

Not all cross-origin requests are treated the same.

Simple requests go straight through. The browser sends the request, gets the response, and checks the CORS headers. A request is “simple” if:

  • Method is GET, HEAD, or POST
  • Only standard headers (Accept, Content-Type with text/plain, multipart/form-data, or application/x-www-form-urlencoded)
  • No custom headers

Preflight requests happen when the request doesn’t qualify as “simple.” Before sending the actual request, the browser sends an OPTIONS request first to ask for permission.

Browser
localhost:3000
API Server
api.example.com
1. OPTIONS /api/data ──────→ preflight: "Can I POST with JSON?"
2. 200 + CORS headers: "Yes, go ahead" ←────── 200 OK
3. POST /api/data ──────→ actual request with JSON body
4. response data + CORS headers ←────── 200 OK + Data
Steps 1-2 are the preflight. Steps 3-4 are the actual request.

Common triggers for preflight:

  • Using Content-Type: application/json (very common with APIs)
  • Sending custom headers like Authorization
  • Using methods like PUT, PATCH, or DELETE

CORS Response Headers

The server controls CORS through these response headers:

Access-Control-Allow-Origin — Which origins are allowed. Can be a specific origin or * (any origin).

Access-Control-Allow-Origin: https://myapp.com

Access-Control-Allow-Methods — Which HTTP methods are allowed for cross-origin requests.

Access-Control-Allow-Methods: GET, POST, PUT, DELETE

Access-Control-Allow-Headers — Which custom headers the client is allowed to send.

Access-Control-Allow-Headers: Content-Type, Authorization

Access-Control-Allow-Credentials — Whether cookies and auth headers are allowed.

Access-Control-Allow-Credentials: true

Access-Control-Max-Age — How long (in seconds) the browser can cache the preflight response. Saves extra OPTIONS requests.

Access-Control-Max-Age: 86400

Fixing CORS in Development

In development, our frontend is usually on localhost:3000 and our API is on localhost:8080. Different ports = different origins = CORS error.

The easiest fix is to proxy the API through the dev server so the browser thinks everything is same-origin:

# Vite config (vite.config.ts)
# export default defineConfig({
#   server: {
#     proxy: {
#       '/api': {
#         target: 'http://localhost:8080',
#         changeOrigin: true,
#       }
#     }
#   }
# })

# Now fetch('/api/users') in the browser goes to:
# localhost:3000/api/users → proxied to → localhost:8080/api/users
# No CORS issue because the browser only sees localhost:3000

This proxy only exists in development. In production, we need proper server-side CORS headers.

Fixing CORS in Production

On the server side, we add the CORS headers to the response. Here’s how in a Node.js/Express app:

# Using the cors middleware (npm install cors)
# const cors = require('cors');
#
# // Allow a specific origin
# app.use(cors({
#   origin: 'https://myapp.com',
#   methods: ['GET', 'POST', 'PUT', 'DELETE'],
#   credentials: true,
# }));

Or in Nginx:

# nginx.conf
# location /api/ {
#     add_header Access-Control-Allow-Origin "https://myapp.com";
#     add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE";
#     add_header Access-Control-Allow-Headers "Content-Type, Authorization";
#
#     if ($request_method = 'OPTIONS') {
#         add_header Access-Control-Max-Age 86400;
#         return 204;
#     }
# }

The Credentials + Wildcard Gotcha

This trips up a lot of people. When we need to send cookies or auth headers with cross-origin requests, we set credentials: 'include' on the fetch call:

# fetch('https://api.example.com/data', {
#   credentials: 'include'  // send cookies cross-origin
# })

But here’s the catch: when credentials are included, the server CANNOT use Access-Control-Allow-Origin: *. It must specify the exact origin.

# THIS WILL FAIL when credentials are included:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

# THIS WORKS:
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true

The browser enforces this strictly. The wildcard * with credentials is considered too permissive and is blocked. We must explicitly list the allowed origin.

Quick Debugging Checklist

When we hit a CORS error, here’s the mental checklist:

  1. Check the browser console — It tells us exactly which header is missing
  2. Check the Network tab — Look for the OPTIONS preflight request. Did it get a 200? Are the CORS headers present?
  3. Is the server sending CORS headers? — Use curl -I to check the response headers directly
  4. Using credentials? — Make sure the origin is explicit, not *
  5. Are the allowed methods/headers correct? — A missing Authorization in Allow-Headers will block JWT auth
# Check what CORS headers a server sends
curl -I -X OPTIONS https://api.example.com/data \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type"

In simple language, CORS is the browser asking the server “Is this frontend allowed to talk to you?” — and the server answers through response headers.


7

REST API Design Basics

intermediate rest api design http

REST stands for Representational State Transfer. It’s an architectural style, not a protocol. There’s no official “REST spec” — it’s a set of conventions that make APIs predictable and easy to use. If we follow these conventions, any developer can look at our API and immediately know how to use it.

Resources, Not Actions

The core idea of REST is that we model everything as resources (nouns), not actions (verbs). The URL represents the resource. The HTTP method represents what we want to do with it.

# Bad — verbs in the URL
GET /getUsers
POST /createUser
POST /deleteUser/42

# Good — resources as nouns, methods as verbs
GET    /users          # list all users
POST   /users          # create a new user
GET    /users/42       # get user 42
PUT    /users/42       # replace user 42 entirely
PATCH  /users/42       # update specific fields on user 42
DELETE /users/42       # delete user 42

Nested resources show relationships naturally: /users/42/posts means “all posts belonging to user 42”. Keep nesting to at most two levels deep — anything deeper gets messy.

HTTP Methods Mapped to CRUD

MethodActionIdempotent?Has Body?
GETRead a resourceYesNo
POSTCreate a new resourceNoYes
PUTReplace an entire resourceYesYes
PATCHUpdate specific fieldsYesYes
DELETERemove a resourceYesNo

Idempotent means calling it multiple times gives the same result. Sending DELETE /users/42 twice still results in user 42 being gone. But POST /users twice creates two users — that’s why POST is not idempotent.

Path Params vs Query Params vs Request Body

Each has a clear use case. Mixing them up is a common mistake.

# Path params — identify a specific resource
GET /users/42              # user ID is part of the resource path

# Query params — filter, sort, paginate a collection
GET /users?status=active&sort=-createdAt&limit=20

# Request body — send data for create/update operations
POST /users
Content-Type: application/json
{"name": "Manish", "email": "manish@example.com"}

Rule of thumb: if it identifies which resource, put it in the path. If it modifies how we view the collection, put it in the query string. If it’s data we’re sending to the server, put it in the body.

Pagination

Never return every record in a collection. Two common patterns exist.

# Offset-based (simple but slow for large datasets)
GET /users?offset=40&limit=20
# Response includes: { "data": [...], "total": 245, "offset": 40, "limit": 20 }

# Cursor-based (better for large/real-time datasets)
GET /users?after=eyJpZCI6NDJ9&limit=20
# Response includes: { "data": [...], "next_cursor": "eyJpZCI6NjJ9" }

Offset-based is easy to implement but breaks when records are inserted or deleted between pages. Cursor-based pagination is what most production APIs use — it’s stable regardless of data changes.

Filtering and Sorting

Keep filtering conventions consistent across all endpoints.

# Filtering — use field names as query params
GET /posts?status=published&author_id=42

# Sorting — prefix with - for descending
GET /posts?sort=-createdAt        # newest first
GET /posts?sort=title             # alphabetical

# Combined
GET /posts?status=published&sort=-createdAt&limit=10

API Versioning

APIs evolve. We need a way to make breaking changes without breaking existing clients.

# URL path versioning (most common, easiest to understand)
GET /v1/users
GET /v2/users

# Header-based versioning (cleaner URLs, harder to test)
GET /users
Accept: application/vnd.myapi.v2+json

Most teams go with URL versioning because it’s visible and easy to debug. We can see the version right in the browser URL bar or in logs.

Status Codes That Matter

We don’t need to memorize all HTTP status codes. These are the ones that show up in real API design.

# Success
200 OK              # GET, PUT, PATCH succeeded
201 Created         # POST created a new resource
204 No Content      # DELETE succeeded, nothing to return

# Client errors
400 Bad Request     # invalid JSON, missing required field
401 Unauthorized    # not logged in (bad/missing auth token)
403 Forbidden       # logged in but not allowed
404 Not Found       # resource doesn't exist
409 Conflict        # duplicate email, version mismatch
422 Unprocessable   # valid JSON but failed validation

# Server errors
500 Internal Error  # something broke on our end
503 Service Unavail # server is down or overloaded

Practical Example: A Complete CRUD Flow

# Create a user
curl -X POST https://api.example.com/v1/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGc..." \
  -d '{"name": "Manish", "email": "manish@example.com"}'
# → 201 Created, returns { "id": 42, "name": "Manish", ... }

# Read that user
curl https://api.example.com/v1/users/42 \
  -H "Authorization: Bearer eyJhbGc..."
# → 200 OK

# Update their name
curl -X PATCH https://api.example.com/v1/users/42 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGc..." \
  -d '{"name": "Manish P"}'
# → 200 OK

# Delete
curl -X DELETE https://api.example.com/v1/users/42 \
  -H "Authorization: Bearer eyJhbGc..."
# → 204 No Content

In simple language, REST is about using URLs as nouns, HTTP methods as verbs, and status codes to tell clients what happened — keep it predictable and any developer can use our API without reading a single doc.


8

Proxy & Reverse Proxy

intermediate proxy reverse-proxy nginx load-balancing

A proxy is a server that sits between two things and handles traffic on behalf of one of them. The direction determines what kind of proxy it is.

Forward Proxy (Client-Side)

A forward proxy sits in front of clients. The client sends requests to the proxy, and the proxy forwards them to the internet. The destination server only sees the proxy’s IP address, not the client’s.

Real-world examples we already use:

  • VPNs — route all our traffic through another server, hiding our real IP
  • Corporate proxies — companies route employee traffic through a proxy to filter content, block sites, and log activity
  • Tor — bounces traffic through multiple proxies for anonymity

The client knows it’s using a proxy. The server doesn’t know (or care).

Reverse Proxy (Server-Side)

A reverse proxy sits in front of servers. The client sends requests to the reverse proxy thinking it’s the actual server. The reverse proxy then decides which backend server should handle the request.

The client doesn’t know a reverse proxy exists. It just talks to api.example.com and gets a response. Behind that domain, there could be 50 servers — the reverse proxy manages all of it.

Forward Proxy (hides the client)
Client A
Forward Proxy
hides client IP
Internet
Server
sees proxy's IP
Reverse Proxy (hides the servers)
Client
sees one domain
Internet
Reverse Proxy
routes to backends
Server 1
Server 2
Server 3

Why Reverse Proxies Are Everywhere

Almost every production web app sits behind a reverse proxy. Here’s why:

  • SSL termination — the reverse proxy handles HTTPS encryption/decryption, so backend servers deal with plain HTTP (simpler and faster)
  • Load balancing — distribute traffic across multiple backend servers
  • Caching — cache static assets and repeated responses, reducing load on backends
  • Security — hide internal server IPs, add rate limiting, block malicious requests
  • Compression — gzip responses before sending them to clients

Nginx as a Reverse Proxy

Nginx is the most popular reverse proxy for web apps. Here’s a basic config that proxies requests to a Node.js backend.

server {
    listen 80;
    server_name api.example.com;

    location / {
        # Forward all requests to our Node app on port 3000
        proxy_pass http://localhost:3000;

        # Pass the original client info to the backend
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Without those proxy_set_header lines, our backend would think every request comes from 127.0.0.1 (the proxy itself). The headers pass through the real client information.

Dev Proxies (Avoiding CORS During Development)

This is one we hit all the time in frontend development. Our React app runs on localhost:5173 but the API is on localhost:3000. Browsers block cross-origin requests — but we can fix this with a dev proxy.

// vite.config.js
export default {
  server: {
    proxy: {
      // Any request to /api/* gets forwarded to port 3000
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
}

Now fetch('/api/users') from our React app hits http://localhost:3000/users under the hood. The browser thinks it’s same-origin, so no CORS issues. This is technically a forward proxy built into our dev server.

CDN as a Reverse Proxy

A CDN (Cloudflare, CloudFront, Fastly) is just a globally distributed reverse proxy. Users hit the CDN’s edge server closest to them. If the edge has a cached copy, it responds immediately. If not, it fetches from our origin server, caches the response, and serves it.

The user never talks to our server directly — the CDN handles everything in between.

In simple language, a forward proxy hides who the client is, a reverse proxy hides who the server is — and almost everything in production sits behind a reverse proxy for SSL, load balancing, and security.


Servers & Infrastructure

9

Linux Commands Every Dev Should Know

beginner linux terminal commands shell

We don’t need to be Linux experts, but we do need to be comfortable on a server. Whether we’re SSH’d into a production box, debugging a Docker container, or setting up a VPS — these commands come up constantly.

File Navigation

pwd                   # where am I? prints current directory
ls                    # list files in current directory
ls -la                # show all files (hidden too), with permissions and sizes
ls -lh                # human-readable file sizes (KB, MB, GB)

cd /var/log           # go to an absolute path
cd ..                 # go up one directory
cd ~                  # go to home directory
cd -                  # go back to the previous directory

Reading Files

cat app.log           # dump entire file to terminal (fine for small files)
less app.log          # scroll through file (q to quit, / to search)
head -n 20 app.log    # first 20 lines
tail -n 50 app.log    # last 50 lines
tail -f app.log       # follow the file in real-time (great for watching logs)

tail -f is something we’ll use all the time in production. It keeps printing new lines as they’re added to the file.

File Manipulation

cp file.txt backup.txt        # copy a file
cp -r src/ src_backup/        # copy a directory recursively
mv old.txt new.txt            # rename or move a file
mkdir -p logs/2024/march      # create nested directories in one go
touch notes.txt               # create an empty file (or update timestamp)
rm file.txt                   # delete a file
rm -rf node_modules/          # delete directory and everything inside — BE CAREFUL

The -rf flag means “recursive” and “force” — it won’t ask for confirmation. One wrong rm -rf on a production server and we’re having a very bad day. Always double-check the path.

File Permissions

Every file has three permission groups: owner, group, and others. Each group can have read (r), write (w), and execute (x) permissions.

Permission String: -rwxr-xr--
type
-
file
owner
rwx
7 (4+2+1)
group
r-x
5 (4+0+1)
others
r--
4 (4+0+0)
r=4   w=2   x=1   →   add them up for octal notation
chmod 755 deploy.sh       # owner: rwx, group: r-x, others: r-x
chmod 644 config.json     # owner: rw-, group: r--, others: r--
chmod +x script.sh        # add execute permission for everyone

chown manish file.txt         # change file owner
chown manish:devs file.txt    # change owner and group

Common permission sets: 755 for scripts we want to run, 644 for config files, 600 for private keys (SSH keys, secrets).

Process Management

ps aux                    # list all running processes
ps aux | grep node        # find a specific process

top                       # real-time process viewer (q to quit)
htop                      # better version of top (install: apt install htop)

kill 1234                 # gracefully stop process with PID 1234
kill -9 1234              # force kill (last resort, no cleanup)

lsof -i :3000            # what's using port 3000?

When a port is “already in use,” lsof -i :PORT tells us exactly which process is hogging it. Then we can kill it.

Text Search and Piping

grep "error" app.log              # find lines containing "error"
grep -r "TODO" src/               # search recursively in a directory
grep -i "warning" app.log         # case-insensitive search
grep -n "error" app.log           # show line numbers

find . -name "*.log"              # find all .log files in current directory
find /var -name "*.conf" -type f  # find config files under /var

# Piping — send output of one command to another
ps aux | grep node | grep -v grep    # find node processes
cat app.log | sort | uniq -c | sort -rn  # count unique log lines

The | (pipe) is one of the most powerful tools in Linux. It chains commands together — the output of one becomes the input of the next.

Environment Variables

echo $PATH                        # print the PATH variable
echo $HOME                        # print home directory
export API_KEY="abc123"           # set a variable for current session
env                               # print all environment variables

# Make it permanent — add to shell config
echo 'export API_KEY="abc123"' >> ~/.bashrc
source ~/.bashrc                  # reload the config without restarting terminal

Networking

curl https://api.example.com/users    # make an HTTP request
curl -s https://example.com | head    # silent mode, show first few lines
wget https://example.com/file.zip     # download a file

ss -tlnp                              # list all listening TCP ports
# (older systems use: netstat -tlnp)

ping google.com                       # test if a host is reachable
nslookup example.com                  # look up DNS records

In simple language, we don’t need to memorize every flag — we need ls, cd, cat, grep, ps, kill, chmod, and curl to be comfortable on any server, and man <command> to look up everything else.


10

Web Servers — Nginx & Apache

intermediate nginx apache web-server reverse-proxy

A web server has one core job: listen on a port, receive HTTP requests, and send back responses. That response could be a static HTML file, an image, or a proxied response from a backend application. Every website on the internet has a web server somewhere in the chain.

What Web Servers Do

At the most basic level, a web server:

  1. Listens on a port (usually 80 for HTTP, 443 for HTTPS)
  2. Receives an HTTP request (like GET /index.html)
  3. Decides what to do — serve a file, forward to a backend, return an error
  4. Responds with the content and a status code

Beyond that, modern web servers handle SSL/TLS, compression, caching, rate limiting, URL rewrites, and reverse proxying — all before our application code even runs.

Nginx vs Apache

These are the two biggest web servers. Nginx now dominates, but it’s worth knowing the difference.

Apache (1995) — the original. Uses a process-per-request model (or thread-per-request). Each incoming connection gets its own process or thread. This works fine for moderate traffic, but under heavy load, spawning thousands of processes eats memory fast.

Nginx (2004) — built to fix Apache’s scalability problem. Uses an event-driven, async architecture. A single worker process handles thousands of connections using an event loop (similar idea to Node.js). This makes Nginx incredibly efficient under high concurrency.

NginxApache
ArchitectureEvent-driven, asyncProcess/thread per request
Memory usageLow (handles 10k+ connections per worker)Higher under load
Static filesExtremely fastGood
ConfigDeclarative blocks.htaccess files + config
Best forReverse proxy, static serving, high concurrencyPHP apps, legacy setups, .htaccess flexibility

For most modern applications, Nginx is the default choice. Apache still shines in shared hosting environments where .htaccess files give per-directory configuration without restarting the server.

Nginx Config Structure

Nginx config follows a nested block structure: http contains server blocks, which contain location blocks.

# /etc/nginx/nginx.conf (simplified)
http {
    # Global settings for all virtual hosts
    gzip on;                          # enable compression
    gzip_types text/plain text/css application/json application/javascript;

    # Include per-site configs (convention)
    include /etc/nginx/conf.d/*.conf;
}

Each site gets its own config file inside conf.d/ or sites-enabled/.

Serving a Static Site

The most basic use case — serving HTML, CSS, JS, and images from a directory.

# /etc/nginx/conf.d/portfolio.conf
server {
    listen 80;
    server_name pman47.cc;

    root /var/www/portfolio/dist;    # where our built files live
    index index.html;

    # Try the exact file, then directory, then fall back to index.html
    # (critical for single-page apps with client-side routing)
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Cache static assets aggressively
    location ~* \.(css|js|png|jpg|svg|woff2)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

The try_files directive is one of the most important directives. Without it, refreshing /about on an SPA would return a 404 because there’s no about file on disk — try_files catches that and serves index.html instead, letting the JavaScript router handle the path.

Reverse Proxying to a Backend App

This is how we run a Node.js, Python, or Go app behind Nginx. Nginx handles SSL, static files, and compression. Our app just handles business logic.

# /etc/nginx/conf.d/api.conf
server {
    listen 80;
    server_name api.pman47.cc;

    # API requests go to our Node.js app
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket support (if needed)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Common Nginx Directives

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

# SSL termination
server {
    listen 443 ssl;
    server_name example.com;
    ssl_certificate /etc/ssl/certs/example.pem;
    ssl_certificate_key /etc/ssl/private/example.key;
}

Useful Nginx Commands

nginx -t                   # test config for syntax errors (always do this!)
systemctl reload nginx     # reload config without dropping connections
systemctl restart nginx    # full restart (drops active connections)
tail -f /var/log/nginx/error.log    # watch error logs in real-time

Always run nginx -t before reloading. A bad config on reload can take our entire site down.

When We Don’t Need a Traditional Web Server

Not every project needs Nginx or Apache. Modern deployment platforms handle the web server layer for us:

  • Vercel / Netlify / Cloudflare Pages — static sites and serverless functions, no web server config needed
  • Docker + Caddy — Caddy auto-provisions SSL certificates and has a much simpler config than Nginx
  • Serverless (AWS Lambda, Cloud Functions) — the cloud provider handles HTTP routing entirely

But the moment we’re managing our own VPS or running Docker containers in production, understanding Nginx becomes essential. It’s the glue between the internet and our application.

In simple language, a web server listens for HTTP requests and figures out what to send back — Nginx does this with an event-driven approach that handles massive traffic with minimal resources, and it’s the go-to choice for reverse proxying, static serving, and SSL termination.


11

Load Balancing Basics

intermediate load-balancing scaling nginx high-availability

One server can only handle so much. At some point, our app gets enough traffic that a single machine maxes out its CPU, memory, or network bandwidth. Load balancing solves this by distributing incoming requests across multiple servers, so no single server gets overwhelmed.

Why We Need Load Balancing

Without a load balancer, we have a single point of failure. If that one server crashes, our entire app goes down. With a load balancer and multiple backend servers:

  • Reliability — if one server dies, traffic routes to the healthy ones
  • Performance — requests spread across machines, each handling a fraction of the load
  • Scalability — need more capacity? Add another server behind the load balancer
User A User B User C User D
↓ ↓ ↓ ↓
Load Balancer
distributes traffic
↙   ↓   ↘
Server 1
A, D → here
Server 2
B → here
Server 3
C → here

Load Balancing Algorithms

The algorithm determines how the load balancer picks which server gets the next request.

Round Robin — each server gets a turn in order: 1, 2, 3, 1, 2, 3, … Simple and works well when all servers are identical.

Least Connections — sends the request to whichever server currently has the fewest active connections. Better when requests take varying amounts of time to process.

IP Hash — hashes the client’s IP address to always route that client to the same server. Useful when we need sticky sessions (more on that below).

Weighted Round Robin — like round robin, but some servers get more traffic. If server 1 has 16 GB RAM and server 2 has 8 GB, we give server 1 double the weight.

AlgorithmBest ForDownside
Round RobinEqual servers, stateless appsIgnores server load
Least ConnectionsVarying request timesSlightly more overhead to track
IP HashSession stickinessUneven distribution if many users share IPs
WeightedMixed server specsManual config, doesn’t adapt dynamically

Layer 4 vs Layer 7 Load Balancing

Load balancers operate at different network layers.

Layer 4 (Transport — TCP/UDP) — looks at IP addresses and ports only. It doesn’t inspect the actual HTTP content. Very fast because it just shuffles TCP packets. Think of it as a mail sorter that only reads the address on the envelope.

Layer 7 (Application — HTTP) — inspects the full HTTP request: URL path, headers, cookies, body. Can make smart routing decisions like “send /api/* to the API servers and /static/* to the CDN.” Slightly more overhead, but much more flexible.

Most modern setups use Layer 7 because we want that content-aware routing.

Health Checks

The load balancer regularly pings each backend server to make sure it’s still alive. If a server stops responding, the load balancer removes it from the pool until it recovers.

upstream backend {
    server 10.0.0.1:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.2:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.3:3000 max_fails=3 fail_timeout=30s;
    # If a server fails 3 health checks in 30s, stop sending traffic to it
}

Without health checks, the load balancer would keep sending requests to a dead server, and users would get errors.

Sticky Sessions

Normally a load balancer doesn’t care which server handled our previous request. But some apps store session data in memory on the server (instead of in a database or Redis). If our next request goes to a different server, our session is lost.

Sticky sessions (session affinity) fix this by always routing the same user to the same server. IP hash is one way. Cookie-based stickiness is another — the load balancer sets a cookie that identifies which backend to use.

The better solution is to make our app stateless — store sessions in Redis or a database, so any server can handle any request. Then we don’t need sticky sessions at all.

Nginx as a Load Balancer

# Define our group of backend servers
upstream api_servers {
    least_conn;                       # use least connections algorithm
    server 10.0.0.1:3000 weight=3;   # this server gets 3x more traffic
    server 10.0.0.2:3000 weight=1;
    server 10.0.0.3:3000 weight=1;
}

server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://api_servers;    # forward to the upstream group
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Horizontal vs Vertical Scaling

These are the two ways to handle more traffic.

Vertical scaling (scale up) — get a bigger server. More CPU, more RAM, faster disk. Simple, but there’s a ceiling — we can’t infinitely upgrade one machine. And it’s still a single point of failure.

Horizontal scaling (scale out) — add more servers behind a load balancer. No theoretical ceiling. If we need more capacity, spin up another identical server. This is what load balancers enable.

Most production systems use horizontal scaling. It’s more resilient (no single point of failure) and more cost-effective at scale (many cheap servers vs one expensive one).

Real-World Tools

  • Nginx / HAProxy — self-managed, run on our own servers
  • AWS ALB (Application Load Balancer) — managed Layer 7 load balancer, integrates with ECS/EKS
  • AWS NLB (Network Load Balancer) — managed Layer 4, ultra-low latency
  • Cloudflare Load Balancing — DNS-level load balancing with global health checks

In simple language, a load balancer is a traffic cop that spreads requests across multiple servers — it keeps our app fast, available, and resilient even when one server goes down.


12

Caching — Browser, CDN & Server

intermediate caching cdn redis performance cache-control

Caching is storing a copy of data somewhere closer (or faster) so we don’t have to fetch it from the original source every time. It’s the single most impactful performance optimization we can make. A well-cached app can handle 100x the traffic with the same hardware.

The Caching Layers

Every request can be cached at multiple points along the way. Each layer closer to the user is faster.

Browser Cache
~0ms • on user's machine
miss?
CDN Edge Cache
~20ms • nearest edge server
miss?
Server Cache (Redis)
~1ms • in-memory on server
miss?
Database
~10-100ms • disk read + query
source of truth

If the browser has a cached copy, we skip the entire network. If the CDN has it, we skip our server entirely. If Redis has it, we skip the slow database query. Every cache hit is a shortcut.

Browser Cache (HTTP Caching)

The browser decides whether to cache a response based on HTTP headers the server sends back. These are the headers that matter.

Cache-Control

This is the most important caching header. It tells the browser exactly what to do.

# Cache for 1 year (static assets like JS, CSS, images)
Cache-Control: public, max-age=31536000, immutable

# Cache but always check with the server first (API responses)
Cache-Control: no-cache

# Never cache at all (sensitive data like bank pages)
Cache-Control: no-store
  • max-age=N — cache for N seconds, don’t even ask the server during that time
  • public — any cache (browser, CDN, proxy) can store this
  • private — only the user’s browser can cache this (not CDN)
  • no-cache — cache it, but always validate with the server before using it
  • no-store — don’t cache at all, period
  • immutable — this file will never change (combined with cache-busted filenames)

ETag and 304 Not Modified

When the cache expires (or no-cache is set), the browser asks the server “has this changed?” instead of downloading the whole thing again.

# First request
GET /api/users
# Server responds with the data + an ETag (a fingerprint of the content)
200 OK
ETag: "abc123"
Content: [the full response]

# Later request — browser sends the ETag back
GET /api/users
If-None-Match: "abc123"

# If nothing changed, server responds with just a status (no body!)
304 Not Modified
# Browser uses its cached copy — saved bandwidth

This is called conditional validation. The Last-Modified / If-Modified-Since headers work the same way, but with timestamps instead of fingerprints.

CDN Caching

A CDN (Content Delivery Network) is a network of servers spread across the globe. When a user in Tokyo requests our site hosted in New York, the CDN serves the cached copy from a server in Tokyo — cutting latency from ~200ms to ~20ms.

How CDN caching works:

  1. User requests style.css from our CDN URL
  2. CDN edge server checks: “do I have a cached copy?”
  3. If yes → return it immediately (cache hit)
  4. If no → fetch from our origin server, cache it, then return it (cache miss)
  5. Future requests to that edge get the cached copy
# Typical CDN cache headers on a response
Cache-Control: public, max-age=86400   # CDN caches for 24 hours
X-Cache: HIT                           # tells us this came from CDN cache
CF-Cache-Status: HIT                   # Cloudflare-specific header

Cache Invalidation at CDN Level

When we deploy new code, we need the CDN to stop serving the old version. Two approaches:

  • Cache busting — change the filename: style.abc123.css. Build tools do this automatically (Vite, Webpack add content hashes). The old filename is never requested again, so the old cache naturally expires.
  • Purge API — tell the CDN to drop its cache: curl -X POST https://api.cloudflare.com/purge_cache. Useful for API responses or HTML pages where we can’t change the URL.

Server-Side Caching

When the database is the bottleneck, we cache query results in memory on the server.

Redis — an in-memory key-value store. We store frequently accessed data (user sessions, API responses, computed results) in Redis. Reading from Redis takes ~1ms. Reading from a database takes 10-100ms.

# Conceptual flow (pseudocode)
# 1. Check Redis first
cached = redis.get("user:42")
if cached:
    return cached           # ~1ms, skip the database

# 2. Cache miss — hit the database
user = db.query("SELECT * FROM users WHERE id = 42")  # ~30ms
redis.set("user:42", user, ex=300)  # cache for 5 minutes
return user

In-memory caching — sometimes we don’t even need Redis. A simple object or Map in our application process works for small datasets that rarely change (like configuration, feature flags, or country lists).

Cache Invalidation Strategies

The hardest problem in caching: knowing when to throw away the cached copy.

TTL (Time To Live) — set an expiration time. After 5 minutes, the cache entry is stale and gets refreshed. Simple, but data could be outdated for up to 5 minutes.

Cache busting with versioned URLs — append a version or hash to the URL: style.css?v=123 or style.abc123.css. When the content changes, the URL changes, so the browser treats it as a new resource.

Key-based invalidation — when we update user 42 in the database, we explicitly delete the Redis key user:42. The next request will miss the cache and fetch fresh data.

Write-through cache — every time we write to the database, we also update the cache. Keeps cache and database always in sync, but adds complexity.

Common Pitfalls

Stale data — caching an API response for 1 hour means users might see outdated info for up to 1 hour. For financial or real-time data, this is unacceptable. Always match TTL to how stale the data can be.

Cache stampede (thundering herd) — imagine 10,000 users hit the same uncached endpoint at the same time. All 10,000 requests miss the cache and slam the database simultaneously. Solutions: lock the cache so only one request fetches from the database while others wait, or pre-warm the cache before it expires.

Caching authenticated data publicly — if we set Cache-Control: public on a response that contains user-specific data, the CDN might serve one user’s data to another. Always use private for personalized responses.

# Good: static assets everyone sees
Cache-Control: public, max-age=31536000, immutable

# Good: API response specific to a user
Cache-Control: private, no-cache

# Dangerous: never cache login pages or sensitive data
Cache-Control: no-store

In simple language, caching stores copies of data closer to where it’s needed — in the browser, at CDN edges, or in Redis on the server — so we skip slow network trips and database queries, making everything dramatically faster.


Docker & Containers

13

Docker Basics

beginner docker containers images volumes

We’ve all heard it — “but it works on my machine!” The app runs perfectly on our laptop, but the moment we ship it to a coworker or a server, everything breaks. Different Node.js version, missing system library, wrong OS. Docker solves this by packaging our app and everything it needs into a single unit called a container.

A container is like a lightweight, isolated box that has its own filesystem, its own dependencies, its own runtime — completely independent of the host machine. If it runs in the container, it runs everywhere.

Images vs Containers

This is the first thing to nail down. An image is a blueprint. A container is a running instance of that blueprint.

Think of it like a class vs an object in OOP. The class defines the structure, but we can create many objects from it. Same thing — one image, many containers.

Docker Image
read-only blueprint
node:20-alpine
↓ docker run
Container 1
running
Container 2
running
Container 3
stopped
Volume
persistent data
survives container restarts
↕ mounted into container
host: /data/postgres
→ container: /var/lib/postgresql

Docker Hub

Docker Hub is like npm but for Docker images. Instead of npm install express, we docker pull node:20-alpine. There are official images for almost everything — Node.js, Python, PostgreSQL, Redis, Nginx. We can also push our own images there (or to other registries like GitHub Container Registry).

Essential Commands

These are the commands we’ll use every day.

# Pull an image from Docker Hub
docker pull node:20-alpine

# Run a container from an image
docker run -d \            # -d = detached mode (runs in background)
  --name my-app \          # give it a name instead of a random one
  -p 3000:3000 \           # map host port 3000 → container port 3000
  node:20-alpine

# List running containers
docker ps                  # only running containers
docker ps -a               # all containers (including stopped ones)

# Stop and remove a container
docker stop my-app         # gracefully stop
docker rm my-app           # remove the container

# Jump inside a running container
docker exec -it my-app sh  # open a shell inside the container

# View container logs
docker logs my-app         # show all logs
docker logs -f my-app      # follow logs in real-time (like tail -f)

Port Mapping

Containers are isolated. By default, nothing inside a container is accessible from the outside. Port mapping creates a tunnel.

The syntax is -p HOST_PORT:CONTAINER_PORT. So -p 8080:3000 means “when someone hits port 8080 on my machine, forward it to port 3000 inside the container.”

# Our Node.js app listens on port 3000 inside the container
# We want to access it on port 8080 on our machine
docker run -d -p 8080:3000 --name api my-api-image

# Now http://localhost:8080 hits the container's port 3000

Volumes — Persistent Data

Here’s the catch with containers — when a container is removed, all its data is gone. Poof. If we’re running a database inside a container (like PostgreSQL), we’d lose all our data every time we restart.

Volumes solve this. They mount a directory from the host machine into the container. Data written to that directory persists even if the container dies.

# Mount a host directory into the container
docker run -d \
  --name postgres \
  -v /my/local/data:/var/lib/postgresql/data \  # host:container
  -e POSTGRES_PASSWORD=secret \
  postgres:15

# The database files are stored on our machine at /my/local/data
# Even if we docker rm postgres, the data survives

There are two types of volumes:

  • Bind mounts — map a specific host path (-v /host/path:/container/path)
  • Named volumes — Docker manages the storage location (-v pgdata:/var/lib/postgresql/data)

Named volumes are preferred for production because Docker handles the path and cleanup.

Images We’ll Use All the Time

Some images we pull regularly:

  • node:20-alpine — Node.js 20 on Alpine Linux (tiny, ~50MB)
  • postgres:15 — PostgreSQL database
  • redis:7-alpine — Redis cache
  • nginx:1.27-alpine — Nginx web server
  • python:3.12-slim — Python on slim Debian

The -alpine and -slim tags are smaller variants. Alpine-based images are usually 5-10x smaller than the default ones. Always prefer them unless we need something specific from the full image.

In simple language, Docker packages our app and its entire environment into a portable container — if it runs in the container, it runs anywhere, on any machine.


14

Writing a Dockerfile

intermediate docker dockerfile multi-stage layers

A Dockerfile is a text file with step-by-step instructions for building a Docker image. Think of it as a recipe — start with a base ingredient (base image), add our code, install dependencies, and define how to run it. Docker reads this file top to bottom and creates an image layer by layer.

Common Instructions

Let’s go through the instructions we’ll use in almost every Dockerfile.

  • FROM — The base image to start from. Every Dockerfile starts here.
  • WORKDIR — Sets the working directory inside the container. Like cd but for the build.
  • COPY — Copies files from our machine into the image.
  • RUN — Executes a command during the build (install deps, compile, etc.).
  • CMD — The default command to run when the container starts.
  • EXPOSE — Documents which port the app listens on (it’s just metadata, doesn’t actually open the port).
  • ENV — Sets environment variables that persist in the running container.
  • ARG — Build-time variables. Only available during docker build, not in the running container.

A Practical Dockerfile for Node.js

Here’s a complete, production-ready Dockerfile for a Node.js app.

# Start with a lightweight Node.js base image
FROM node:20-alpine

# Set working directory inside the container
WORKDIR /app

# Copy package files first (for layer caching — explained below)
COPY package.json package-lock.json ./

# Install dependencies
RUN npm ci --only=production

# Now copy the rest of the application code
COPY . .

# Document the port our app listens on
EXPOSE 3000

# Default command when container starts
CMD ["node", "server.js"]

Build and run it:

# Build the image (the dot means "use current directory as context")
docker build -t my-app .

# Run it
docker run -d -p 3000:3000 --name my-app my-app

Layer Caching — Why Order Matters

This is the most important optimization concept in Dockerfiles. Docker builds images in layers. Each instruction creates a new layer, and Docker caches each one. If a layer hasn’t changed, Docker reuses the cached version instead of rebuilding it.

Here’s why we copy package.json separately before copying the rest of the code:

Docker Layer Cache
Layer 1: FROM node:20-alpine
cached
Layer 2: COPY package*.json
cached (deps didn't change)
Layer 3: RUN npm ci
cached (package.json unchanged)
Layer 4: COPY . .
rebuilt (code changed)
When only our code changes, npm install is skipped entirely — saves minutes per build.

If we did COPY . . first and then RUN npm ci, Docker would reinstall all dependencies every time we changed even a single line of code. By copying package.json first, npm ci only reruns when our dependencies actually change.

Rule of thumb: put things that change least at the top, things that change most at the bottom.

Multi-Stage Builds

In a single-stage build, our final image includes everything — dev dependencies, build tools, source files. That’s wasteful. Multi-stage builds let us use one stage to build the app and a second stage to run it, keeping only what we need.

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                          # install ALL deps (including devDeps)
COPY . .
RUN npm run build                   # compile TypeScript, bundle, etc.

# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production        # only production deps
COPY --from=builder /app/dist ./dist # copy built files from stage 1

EXPOSE 3000
CMD ["node", "dist/server.js"]

The final image only has production dependencies and the compiled output. No TypeScript compiler, no dev tools, no source code. This can cut image size by 50-80%.

The .dockerignore File

Just like .gitignore keeps files out of git, .dockerignore keeps files out of the Docker build context. Without it, COPY . . sends everything to the Docker daemon — including node_modules, .git, .env, and other junk.

# .dockerignore
node_modules          # we install fresh inside the container
.git                  # no need for git history
.env                  # never bake secrets into images
.env.*                # environment-specific files
dist                  # we build fresh inside the container
*.md                  # docs don't belong in the image
.DS_Store             # macOS junk

This makes builds faster (smaller context to send) and more secure (no secrets in the image).

Image Size Optimization Tips

A few quick wins for smaller images:

  • Use Alpine-based imagesnode:20-alpine is ~50MB vs node:20 at ~350MB
  • Multi-stage builds — only ship what the app needs to run
  • Combine RUN commands — each RUN creates a layer. Combine related commands with &&
  • Clean up in the same layerRUN apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/*
  • Use .dockerignore — keep unnecessary files out of the build context
# Bad — 3 layers for related operations
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean

# Good — 1 layer, cleaned up
RUN apt-get update && \
    apt-get install -y curl && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

In simple language, a Dockerfile is a recipe that builds our app into a portable image — and the order of instructions matters because Docker caches each layer to speed up rebuilds.


15

Docker Compose

intermediate docker docker-compose yaml multi-container

Most real apps don’t run alone. We have our app, a database, maybe a cache, a reverse proxy. Running each container manually with long docker run commands gets painful fast. Docker Compose lets us define all our services in a single YAML file and bring everything up with one command.

Instead of running five docker run commands with flags we’ll definitely mistype, we write a docker-compose.yml once and run docker compose up. Done.

The docker-compose.yml Structure

A compose file defines services (containers), networks (how they talk to each other), and volumes (persistent data). Here’s the anatomy:

# docker-compose.yml
services:
  # Each service becomes a container
  app:
    build: .                      # build from Dockerfile in current dir
    ports:
      - "3000:3000"               # host:container port mapping
    environment:
      - NODE_ENV=production       # env vars
      - DATABASE_URL=postgres://user:pass@db:5432/mydb
    volumes:
      - ./src:/app/src            # bind mount for hot reload
    depends_on:
      - db                        # start db before app
      - redis

  db:
    image: postgres:15            # pull from Docker Hub
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: mydb
    volumes:
      - pgdata:/var/lib/postgresql/data   # named volume
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

# Named volumes (Docker manages the storage location)
volumes:
  pgdata:

That’s it. Three services, networking, persistence — all in one file.

A Complete Practical Example

Let’s say we’re building a Node.js API with PostgreSQL and Redis. Here’s a real-world compose file:

services:
  api:
    build:
      context: .                  # Dockerfile location
      dockerfile: Dockerfile      # explicit Dockerfile name
    ports:
      - "3000:3000"
    env_file:
      - .env                      # load vars from .env file
    volumes:
      - ./src:/app/src            # hot reload during development
    depends_on:
      - postgres
      - redis
    restart: unless-stopped        # auto-restart on crash

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: ${DB_USER}    # reads from .env or shell env
      POSTGRES_PASSWORD: ${DB_PASS}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb  # override default command
    ports:
      - "6379:6379"

volumes:
  pgdata:

Essential Commands

These are the compose commands we use daily.

# Start all services in the background
docker compose up -d

# Stop and remove all containers, networks
docker compose down

# Stop and remove everything INCLUDING volumes (careful — deletes data)
docker compose down -v

# Rebuild images before starting (after Dockerfile changes)
docker compose build
docker compose up -d --build      # shorthand: build + start

# View logs from all services
docker compose logs -f            # follow all logs
docker compose logs -f api        # follow only the api service

# Run a command inside a running service
docker compose exec postgres psql -U user -d mydb

# List running services
docker compose ps

Networking — Services Talk by Name

This is one of the best parts of Compose. All services defined in the same compose file are automatically on the same network. They can reach each other using the service name as the hostname.

Compose Network (auto-created)
api
:3000
postgres
:5432
redis
:6379
api connects to postgres via postgres://user:pass@postgres:5432/mydb
api connects to redis via redis://redis:6379

Notice we use postgres and redis as hostnames — not localhost. Inside the Compose network, each service is reachable by its name. This is why the database URL uses @db:5432 instead of @localhost:5432.

Environment Variables in Compose

There are three ways to pass env vars to services:

services:
  app:
    # 1. Inline — directly in the compose file
    environment:
      - NODE_ENV=production
      - API_KEY=abc123

    # 2. From a file — load all vars from a .env file
    env_file:
      - .env
      - .env.local               # can load multiple files

    # 3. Variable substitution — reference host env or .env file
    environment:
      - DB_HOST=${DB_HOST:-localhost}   # default value if not set

Compose automatically reads a .env file in the same directory as the compose file. So ${DB_USER} in the YAML will be replaced with whatever DB_USER is set to in .env.

The depends_on Gotcha

depends_on controls startup order — it makes sure db starts before app. But here’s the catch: it only waits for the container to start, not for the service inside it to be ready.

PostgreSQL takes a few seconds to initialize. If our app tries to connect immediately, it might fail because Postgres isn’t accepting connections yet.

services:
  app:
    depends_on:
      db:
        condition: service_healthy   # wait for healthcheck to pass
  db:
    image: postgres:15
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 5s
      retries: 5

With condition: service_healthy, Compose waits until the database healthcheck passes before starting our app. This is the proper way to handle it.

Dev vs Production Compose Files

For development we want hot reload, exposed ports for debugging, and verbose logging. For production, none of that. We can use multiple compose files:

# Development — uses both files, dev overrides base
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# Production — base file is usually enough
docker compose up -d

In simple language, Docker Compose lets us define our entire multi-container stack in one YAML file — services talk to each other by name, volumes keep data alive, and one command brings everything up or tears it down.


CI/CD & Operations

16

Environment Variables & Configuration

beginner env-variables configuration secrets dotenv 12-factor

Hardcoding a database password in our source code is a recipe for disaster. Someone pushes it to GitHub, a bot scrapes it within minutes, and now our database is compromised. Environment variables solve this — they let us store configuration outside our code, where it belongs.

An environment variable is just a key-value pair set in the operating system’s environment. Our app reads it at runtime. Different environments (dev, staging, prod) get different values. Same code, different config.

Why We Use Env Vars

Three big reasons:

  1. Security — Secrets (API keys, database passwords, tokens) never touch source code or git history
  2. Flexibility — Same app, different config per environment. Dev connects to local Postgres, prod connects to a cloud database.
  3. Portability — The app doesn’t care where it runs. It just reads the environment.

Setting and Reading Env Vars

At the OS level, env vars are straightforward.

# Set an env var in the terminal (only for this session)
export DATABASE_URL="postgres://user:pass@localhost:5432/mydb"
export API_KEY="sk-abc123"

# Read it
echo $DATABASE_URL

# Set for a single command only
DATABASE_URL="postgres://localhost/test" node server.js

# See all env vars
printenv

In our code, we read them from the environment:

// Node.js — process.env
const dbUrl = process.env.DATABASE_URL;
const port = process.env.PORT || 3000;  // fallback to 3000 if not set
const isProduction = process.env.NODE_ENV === "production";

// Never log secrets, but fine for non-sensitive config
console.log(`Starting server on port ${port}`);
# Python — os.environ
import os

db_url = os.environ.get("DATABASE_URL")        # returns None if not set
port = int(os.environ.get("PORT", "3000"))      # default to "3000"
api_key = os.environ["API_KEY"]                 # raises KeyError if missing

.env Files and dotenv

Typing export for every variable is tedious. .env files let us define them all in one place.

# .env
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
API_KEY=sk-abc123
NODE_ENV=development
PORT=3000

Then we use a library to load them into our app:

// Node.js — npm install dotenv
require("dotenv").config();  // loads .env into process.env
// Or in ESM: import "dotenv/config";

const dbUrl = process.env.DATABASE_URL;  // now available
# Python — pip install python-dotenv
from dotenv import load_dotenv
import os

load_dotenv()  # loads .env into os.environ
db_url = os.environ.get("DATABASE_URL")

Environment-Specific Config

For different environments, we use different .env files:

.env                  # shared defaults
.env.development      # dev-specific overrides
.env.production       # production values
.env.test             # test environment

Some frameworks (like Vite, Next.js, CRA) automatically load these based on the NODE_ENV. For vanilla Node.js, we handle it ourselves:

// Load the right .env file based on NODE_ENV
const dotenv = require("dotenv");
const env = process.env.NODE_ENV || "development";
dotenv.config({ path: `.env.${env}` });

The Golden Rule: Never Commit Secrets

This is non-negotiable. The .env file should always be in .gitignore.

# .gitignore
.env
.env.*
!.env.example          # keep an example file in git (no real values)

We do commit a .env.example file with placeholder values so new developers know what variables they need:

# .env.example — commit this to git
DATABASE_URL=postgres://user:password@localhost:5432/dbname
API_KEY=your-api-key-here
NODE_ENV=development

If we accidentally commit a secret: Don’t just delete it in the next commit. The secret is in the git history forever. We need to rotate it immediately — generate a new API key, change the password, revoke the token. Then use git filter-branch or BFG Repo Cleaner to scrub the history if needed.

The 12-Factor App Config Principle

The 12-factor methodology says: store config in the environment. Not in code, not in config files baked into the deploy. The environment is the only place that changes between deploys.

This means we should be able to open-source our entire codebase without exposing any credentials. If we can’t, our config separation isn’t good enough.

Env Vars in Docker

Docker has several ways to pass env vars to containers.

# 1. Inline with -e flag
docker run -e NODE_ENV=production -e API_KEY=abc123 my-app

# 2. From a file with --env-file
docker run --env-file .env my-app
# 3. In Dockerfile with ENV (baked into the image — not for secrets!)
ENV NODE_ENV=production
ENV PORT=3000

# 4. ARG is for build-time only — not available at runtime
ARG BUILD_VERSION=1.0
RUN echo "Building version $BUILD_VERSION"
# 5. In docker-compose.yml
services:
  app:
    environment:
      - NODE_ENV=production     # inline
      - API_KEY=${API_KEY}      # from host env or .env
    env_file:
      - .env.production         # from a file

Important: Never put secrets in ENV instructions in Dockerfiles. They get baked into the image and anyone who pulls the image can see them. Use runtime env vars (-e or env_file) instead.

Env Vars in CI/CD

Every CI/CD platform has a way to store secrets securely:

  • GitHub Actions — Settings → Secrets → Actions secrets, accessed as ${{ secrets.API_KEY }}
  • GitLab CI — Settings → CI/CD → Variables
  • Vercel / Netlify — Environment variables in project settings

These are encrypted at rest and injected at runtime. They never show up in logs (most platforms auto-mask them).

In simple language, environment variables keep our secrets out of code and let the same app behave differently in dev, staging, and production — just by changing the environment, not the code.


17

CI/CD

intermediate ci-cd github-actions automation deployment pipeline

Before CI/CD, deployments were a manual nightmare. Someone runs the tests locally (maybe), builds the app, copies files to the server, restarts things, and prays it works. With CI/CD, all of that happens automatically every time we push code. No manual steps, no human error, no prayers.

CI vs CD

CI (Continuous Integration) — Every time we push code or open a PR, the system automatically:

  • Pulls the latest code
  • Installs dependencies
  • Runs the linter
  • Runs the tests
  • Builds the app

If any step fails, we know immediately. No more “it works on my machine.”

CD (Continuous Delivery / Continuous Deployment) — After CI passes, what happens next?

  • Continuous Delivery — The app is built and ready to deploy, but a human clicks the button. We have a release candidate sitting there, validated and packaged.
  • Continuous Deployment — No human in the loop. CI passes, deploy happens automatically. Merge to main = live in production.

Most teams start with Continuous Delivery and move to Continuous Deployment once they trust their test suite.

The Pipeline Flow

A typical CI/CD pipeline looks like this:

CI/CD Pipeline
Push
trigger
Lint
code style
Test
unit + integration
Build
compile / bundle
Deploy
staging / prod
CI ←——————————————→
CD →

The CI part catches bugs early. The CD part ensures consistent deployments. Together, they give us confidence that what’s in main is always shippable.

GitHub Actions Basics

GitHub Actions is the most popular CI/CD platform for GitHub repos. Workflows are YAML files that live in .github/workflows/. Let’s break down the key concepts:

  • Workflow — A YAML file that defines the automation. Triggered by events.
  • Trigger — What starts the workflow: push, pull_request, schedule, workflow_dispatch (manual).
  • Job — A group of steps that run on the same runner. Jobs run in parallel by default.
  • Step — An individual task: run a command, use an action, etc.
  • Action — A reusable piece of automation (like actions/checkout or actions/setup-node).
  • Runner — The machine that runs the job (GitHub provides ubuntu-latest, macos-latest, etc.).

A Practical GitHub Actions Workflow

Here’s a real-world workflow that lints and tests on PRs, then deploys on merge to main:

# .github/workflows/ci-cd.yml
name: CI/CD Pipeline

# When to run
on:
  push:
    branches: [main]             # deploy on merge to main
  pull_request:
    branches: [main]             # lint + test on PRs

jobs:
  # Job 1: Lint and Test (runs on every push and PR)
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4          # clone the repo
      - uses: actions/setup-node@v4        # install Node.js
        with:
          node-version: 20
          cache: "npm"                     # cache node_modules
      - run: npm ci                        # install deps
      - run: npm run lint                  # run linter
      - run: npm test                      # run tests

  # Job 2: Deploy (only on push to main, after CI passes)
  deploy:
    needs: ci                              # wait for CI to pass
    if: github.ref == 'refs/heads/main'    # only on main branch
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - name: Deploy to server
        env:
          SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}  # from GitHub Secrets
        run: |
          echo "$SSH_KEY" > key.pem
          chmod 600 key.pem
          rsync -avz --delete dist/ user@server:/var/www/app/ -e "ssh -i key.pem"

A few things to notice:

  • needs: ci makes the deploy job wait for CI to pass
  • if: github.ref == 'refs/heads/main' ensures we only deploy from the main branch
  • Secrets are stored in GitHub Settings, never in the workflow file

Branch Strategy

CI/CD works best with a good branching strategy:

  1. main — Always deployable. Protected branch, no direct pushes.
  2. Feature branchesfeature/add-auth, fix/login-bug. Short-lived.
  3. Pull Requests — Feature branch → main. CI runs on every PR. Code review required.
  4. Merge — Once PR is approved and CI passes, merge to main → CD triggers deployment.
# Typical workflow
git checkout -b feature/add-auth    # create feature branch
# ... write code ...
git push -u origin feature/add-auth # push and open PR
# CI runs automatically on the PR
# Teammate reviews and approves
# Merge to main → deploy happens automatically

Build Caching

CI builds can be slow. Caching dependencies between runs saves a lot of time.

# GitHub Actions — cache npm dependencies
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: "npm"                   # built-in caching

# Or manual caching for anything
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: ${{ runner.os }}-npm-

The hashFiles('package-lock.json') key means the cache is busted only when dependencies change. If package-lock.json hasn’t changed, npm install finishes in seconds.

Why CI/CD Matters

Without CI/CD:

  • “Did anyone run the tests before merging?” — Nobody knows
  • Deployments are scary, manual, and different every time
  • Bugs make it to production because nobody tested that edge case

With CI/CD:

  • Every PR is automatically tested — broken code can’t merge
  • Deployments are consistent and repeatable
  • The team ships faster because they trust the pipeline

The investment in setting up CI/CD pays for itself after the first week.

In simple language, CI/CD automates the entire path from code push to production deployment — CI catches bugs early by running tests on every change, and CD makes sure deployments are consistent and hands-free.


18

Logging & Monitoring Basics

intermediate logging monitoring observability rate-limiting

It’s 2 AM. Our app is down. Users are tweeting about it. We have no idea what happened. No logs, no metrics, no alerts. We’re flying blind.

This is what happens when we skip logging and monitoring. They’re not glamorous, but they’re the difference between “we detected the issue and fixed it in 5 minutes” and “we found out from a user’s angry email 3 hours later.”

Logging — What Happened?

Logs are the record of what our application is doing. Every request, every error, every decision the app makes can be captured in a log. When something goes wrong, logs are our detective.

Log Levels

Not all logs are equal. We use levels to categorize severity:

  • debug — Verbose details for development. “User object loaded with 15 fields.” Turn off in production.
  • info — Normal operations. “Server started on port 3000.” “User logged in.”
  • warn — Something unexpected but not broken. “API response took 5s.” “Disk usage at 80%.”
  • error — Something failed. “Database connection refused.” “Payment API returned 500.”
  • fatal — The app is going down. “Out of memory.” “Unhandled exception — shutting down.”
// Node.js with Pino (fast, structured logger)
const pino = require("pino");
const logger = pino({ level: "info" }); // only info and above in prod

logger.debug("Fetching user data");        // won't show in production
logger.info("Server started on port 3000");
logger.warn({ responseTime: 5200 }, "Slow API response");
logger.error({ err, userId: 123 }, "Failed to process payment");
logger.fatal("Unhandled exception — shutting down");

Rule of thumb: In production, set level to info. In development, set it to debug. Never log at debug level in production — it generates too much noise and eats disk space.

Structured Logging (JSON Logs)

Plain text logs like "User 123 logged in at 2024-03-15" are easy to read but terrible to search. When we have millions of logs, we need structure.

// Bad — plain text (hard to parse, hard to search)
console.log("User 123 logged in from 192.168.1.1");

// Good — structured JSON (easy to filter, search, aggregate)
logger.info({
  event: "user_login",
  userId: 123,
  ip: "192.168.1.1",
  method: "oauth"
});
// Output: {"level":"info","event":"user_login","userId":123,"ip":"192.168.1.1","method":"oauth","time":1710489600}

With JSON logs, we can query things like “show me all errors where userId is 123” or “find all logins from this IP in the last hour.” Tools like Elasticsearch or CloudWatch can index and search these instantly.

Centralized Logging

When our app runs on multiple servers or containers, logs are scattered everywhere. Centralized logging collects them in one place.

  • ELK Stack — Elasticsearch (stores/indexes logs) + Logstash (processes logs) + Kibana (visual dashboard). Self-hosted, powerful, but heavy to run.
  • CloudWatch — AWS’s built-in logging. Zero setup for AWS services.
  • Datadog — SaaS logging + monitoring + alerting. Easy to set up, expensive at scale.
  • Grafana + Loki — Lightweight alternative to ELK. Loki stores logs, Grafana visualizes.

The pattern is always the same: app writes logs → a collector ships them → a store indexes them → a dashboard lets us search and visualize.

Monitoring — Is It Healthy?

Logging tells us what happened. Monitoring tells us what’s happening right now. It continuously tracks the health and performance of our system.

Key Metrics to Watch

  • Response time — How fast our API responds. Track p50 (median), p95 (95th percentile), and p99 (slowest 1%). If p99 is 10 seconds, 1% of users are having a terrible experience.
  • Error rate — What percentage of requests are failing (5xx responses). Normal is near 0%. Above 1% is a red flag.
  • Uptime — Is the service reachable? 99.9% uptime = 8.7 hours of downtime per year.
  • CPU / Memory — Resource utilization. Spiking CPU might mean a runaway process or traffic surge.
  • Request rate — Requests per second (RPS). A sudden spike could mean a DDoS attack or a viral feature.

Health Check Endpoints

Every production app should expose a health check endpoint. Load balancers and monitoring tools hit this endpoint to know if the app is alive.

// Basic health check — is the server running?
app.get("/health", (req, res) => {
  res.status(200).json({ status: "ok" });
});

// Deep health check — are dependencies healthy too?
app.get("/ready", async (req, res) => {
  try {
    await db.query("SELECT 1");               // database alive?
    await redis.ping();                        // cache alive?
    res.status(200).json({
      status: "ok",
      db: "connected",
      redis: "connected",
      uptime: process.uptime()               // seconds since start
    });
  } catch (err) {
    res.status(503).json({                    // 503 = Service Unavailable
      status: "degraded",
      error: err.message
    });
  }
});

The /health endpoint is simple — is the process running? The /ready endpoint is deeper — can the app actually serve requests? Load balancers use /ready to decide whether to route traffic to this instance.

Monitoring vs Logging vs Alerting

Logging
What happened?
"User 123 got a 500 error at 2:03 AM"
Monitoring
Is it healthy right now?
"Error rate is at 5%, p99 latency is 8s"
Alerting
Tell me when it breaks!
"Slack: error rate > 2% for 5 minutes"

They work together. Monitoring detects the problem (“error rate spiked”). Alerting notifies us (“Slack message at 2 AM”). Logging helps us diagnose it (“here’s the stack trace from 2:03 AM”).

Rate Limiting

Rate limiting protects our API from abuse — whether it’s a misbehaving client, a bot, or a DDoS attack. It restricts how many requests a client can make in a given time window.

How It Works

Two common algorithms:

Token Bucket — Imagine a bucket that holds 100 tokens. Each request takes one token. The bucket refills at a steady rate (say, 10 tokens per second). If the bucket is empty, the request is rejected. This allows short bursts while enforcing an average rate.

Sliding Window — Count requests in a moving time window. “Max 100 requests per minute.” If a client has made 100 requests in the last 60 seconds, the next one is rejected.

When rate limited, the server responds with:

HTTP/1.1 429 Too Many Requests
Retry-After: 30          # try again in 30 seconds
// Simple rate limiting with express-rate-limit
const rateLimit = require("express-rate-limit");

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,     // 15-minute window
  max: 100,                      // max 100 requests per window per IP
  message: {
    error: "Too many requests, please try again later",
    retryAfter: 900              // seconds until window resets
  }
});

app.use("/api/", limiter);       // apply to all /api/ routes

Rate limiting is usually applied per IP address or per API key. Public APIs almost always have rate limits — GitHub’s API allows 5000 requests per hour with auth, 60 without.

Putting It All Together

A production-ready app needs all three: logging to record events, monitoring to track health, and alerting to wake us up when something breaks. Start simple — structured JSON logs, a /health endpoint, and basic rate limiting. We can add more sophisticated tools as the app grows.

In simple language, logging records what happened, monitoring watches what’s happening right now, and alerting tells us when something goes wrong — together they make sure we find problems before our users do.