Web Server
Twenty lines of shell that a browser will talk to.
A web server is simply a program that waits for a request and sends a response. To prove it, we build one out of bash, netcat and a named pipe — you can read every line of it — and then move to Caddy, the server that runs real sites with HTTPS included.
A web server is a program that answers
Strip away the racks and the logos and a web is one program doing one thing in a loop: wait for someone to connect, read what they asked for, send text back, and go back to waiting.
The word server means two things and people mix them up constantly. There is the machine sitting in a data centre, and there is the program listening on a . Here we mean the program — and it runs just as happily on the laptop in front of you.
One visit, four steps
- ListenThe program claims a port — 8084 in our example — and waits. A port is just a numbered door on the machine, so two programs cannot hold the same one.
- Read the requestA connects and sends a few lines of text. The first line says which method and which path it wants, for example a GET of the path /.
- DecideThe server works out what to send: read a file from disk, run some code, query a . Our example always sends the same file, which is the only reason it fits in twenty lines.
- Respond and closeIt writes a status line, a few headers, one blank line and the content, then closes the connection and loops round for the next visitor.
Every server you have heard of — Apache, Nginx, Caddy, the Node process behind this very site — does the four steps above. The difference is that they do it for thousands of people at once, safely, over HTTPS. The shape is identical.
A web server in one shell script
Here is a complete web written in bash. It opens a , reads the request line, logs it, and sends index.html back as a proper response. Twenty lines, no libraries, and a cannot tell the difference.
Two commands do the heavy lifting. netcat (nc) speaks the network, and mkfifo makes a named pipe so the answer we write can be fed back into the connection nc is holding open.
#!/bin/bash
PORT=8084
PIPE=simple-web-server-pipe
rm -f "$PIPE"
mkfifo "$PIPE"
response() {
read -r method path _
echo "[$(date)] $method $path" >&2
index=$(cat index.html)
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n%s' \
"$index"
}
while true; do
response < "$PIPE" | nc -l "$PORT" > "$PIPE"
done
Line by line
PORT=8084The port to listen on. Anything above 1024 works without administrator rights, and 8084 is unlikely to collide with something you already run.PIPE=simple-web-server-pipeThe name of the pipe file. It appears in the folder like an ordinary file, but nothing is ever stored in it — it only passes data between two commands.mkfifo "$PIPE"Creates the named pipe (a FIFO). The line above deletes any leftover from a previous run, because mkfifo refuses to overwrite an existing file.read -r method path _Reads the first line of the request and splits it into method, path and the rest. Everything after it — the browser's headers — is ignored by this server.echo "[$(date)] $method $path" >&2Prints a log line. The redirection sends it to standard error so it appears on your terminal instead of being sent to the browser as part of the page.index=$(cat index.html)Reads index.html from the current folder into a variable. This is why the server must be started from the folder that holds the file.printf 'HTTP/1.1 200 OK\r\n…'Writes the response: a status line, the content type, a header saying the connection ends here, then a blank line and the page. The blank line is what separates headers from content, and HTTP demands carriage-return-and-newline on every one of them.response < "$PIPE" | nc -l "$PORT" > "$PIPE"The trick that makes it work. nc reads our response from the pipe and writes the browser's request back into it, so the two halves feed each other.while true; do … doneThe loop. nc handles one connection and exits, so without this the server would answer a single visitor and stop.
The request has to reach the function before the function's answer can reach nc — a circle. A shell pipeline only flows one way, so the named pipe closes the loop: the response goes into nc through the pipeline, and the request comes back out through the FIFO. It is the smallest way to make two commands talk to each other in both directions.
Run it yourself
Save the script as server.sh, put an index.html next to it, and start it. Leave that terminal running and use a second one to make requests.
# 1. create an index.html in the same folder
echo '<h1>Hello</h1>' > index.html
# 2. save the script above as server.sh and start it
bash server.sh
# 3. open it in a browser (or check from a second terminal)
open http://localhost:8084
curl http://localhost:8084
Watch the first terminal while you reload the browser: one log line appears per request. Stop the server with Control-C, and delete the pipe file afterwards if it is still there. On Linux the nc command may need an extra -p flag before the port; on macOS the version shipped with the system works as written.
Learn the shell commands used hereWhat actually travels down the wire
Open the connection and you find plain text you could have typed yourself. This is the whole reason the shell is possible — and the reason you can a web problem by reading, rather than guessing.
The request the browser sends
GET / HTTP/1.1
Host: localhost:8084
User-Agent: Mozilla/5.0
Accept: text/html
- The first line is the only part our server reads: the method (GET means fetch, POST means send something), the path, and the protocol version.
- Host says which site is wanted. One server can hold hundreds of domains on one address, and this header is how it tells them apart.
- The rest are the introducing itself: what software it is, what formats it can read, what languages it prefers. A server may use them or ignore them.
The response the server sends
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Connection: close
<h1>Hello</h1>
- The status line: the protocol version and a code saying how it went. 200 means the request succeeded.
- Content-Type tells the browser how to read what follows. Send HTML with the wrong type and the browser will show your tags as text instead of a page.
- The charset part is not optional in practice. Without it, any non-English text is likely to arrive as garbled characters.
- Then one blank line, and everything after it is the content. That blank line is the entire border between the headers and the page.
Status codes worth knowing
| Code | Meaning |
|---|---|
200 OK | Success. The response contains what was asked for. Codes in the 200s all mean some flavour of that. |
301 Moved Permanently | It moved, permanently. The new address is in the Location header and the browser follows it automatically — this is how a site sends visitors from http to https. |
404 Not Found | The server is fine but that path does not exist. Codes in the 400s mean the problem is in the request. |
500 Internal Server Error | The server broke while handling a perfectly good request. Codes in the 500s mean the problem is yours to fix, and the details are in the server log. |
What this server cannot do
The example is honest teaching material and a poor service. Knowing exactly why it falls short is what a real server has to solve for you.
- One visitor at a time. nc handles a single connection, so while it is busy everyone else waits.
- It ignores the path. Ask for anything at all and you still get index.html — there is no routing and no second page.
- It sends everything as . Images, stylesheets and downloads all need their own content type, and this has exactly one.
- No . Everything travels in the clear, so any network in between can read it or change it on the way.
- No safety at all. It never checks the size of a request, never times out, and would happily read a file path someone else chose if you extended it carelessly.
Run it on your own machine to see HTTP with your own eyes, then use a real server for anything anyone else will visit. The next section is that real server — and it is barely harder to start than this one.
Caddy: a real web server that stays readable
Caddy is a modern web in a single executable file. It is worth learning right after the shell example because it keeps the same feeling — a few readable lines and it is running — while solving everything the shell version cannot.
Its headline feature is automatic . Give it a real and it obtains a certificate, installs it, renews it before it expires, and redirects visitors from http to https. On other servers that is a chore you repeat every few months.
Installing it
# macOS
brew install caddy
# Ubuntu / Debian
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
sudo apt update && sudo apt install caddy
# Docker alone is enough
docker run -p 8080:80 -v "$PWD":/usr/share/caddy caddy
Serving a folder in one command
No configuration file needed. Go to the folder holding your index.html and run one command — this is the fastest way to show a page to someone on the same network.
# serve this folder's index.html on port 8080
caddy file-server --listen :8080
# add a directory listing for folders without an index.html
caddy file-server --listen :8080 --browse
The Caddyfile
For anything permanent you write a Caddyfile. It is a plain text file, and a whole site can be four lines long.
example.com {
root * /var/www/html
file_server
encode gzip zstd
}
example.com {The site address. Write a real domain here and Caddy arranges the HTTPS certificate on its own; write localhost and it serves plain http for local work.root * /var/www/htmlWhere the files live on disk. The star means it applies to every path of this site.file_serverActually serve those files. Without this line Caddy answers the request with nothing.encode gzip zstdCompress responses before sending them. One word, and pages arrive noticeably faster on slow connections.
Caddy asks Let's Encrypt for a free certificate, which requires the domain's to point at your machine and ports 80 and 443 to be reachable. If a proxy in front of it answers those ports instead, the check fails and the certificate never arrives — which is the single most common reason a first Caddy setup does not turn green.
Reverse proxy: passing the request on
Most sites are not files on disk; they are an application listening on some local . A is a server that takes the public request and hands it to that application, then returns its answer. One line does it.
getpes.com {
reverse_proxy pes-web:3000
}
dl.getpes.com {
root * /srv/files
file_server browse
}
This is also how one machine hosts many sites. Only one program may hold port 443, so Caddy holds it, reads which domain each visitor asked for, and forwards each one to a different application behind it.
The commands you need
| Command | What it does |
|---|---|
caddy run | Run in the foreground and print the log to the terminal. This is what you want while you are still getting the configuration right. |
caddy start | Run in the background and return your prompt. Fine for a quick demo; a proper server runs it as a system service instead. |
caddy reload | Load the changed configuration without dropping a single connection. Editing a Caddyfile does not require a restart. |
caddy fmt --overwrite | Reformat the file properly in place. Indentation matters more than it looks, and this settles every argument about it. |
caddy validate | Check the configuration for mistakes without starting anything. Run it before you reload a live server. |
One Caddy holds ports 80 and 443 on the PES server and splits traffic by domain name: the main site goes to the application container, the download subdomain is served straight from disk. Adding a new site means adding one file like the one above and reloading — no restart, and no second program fighting for the port.
Docker: Caddy in a box, then real sites on the internet
packs a program together with everything it needs to run — libraries, settings, even a small operating system — into a single , and runs that image as a . A container behaves the same on your laptop and on a rented server, so the old excuse "but it works on my machine" disappears.
Caddy is already a single file, so why bother? Because a real soon runs more than one thing — a site, an app, a . Docker gives each one its own box that you start, stop, update and delete with the same few commands, without installing anything else on the server or disturbing its neighbours.
Four words to know first
- ImageA frozen package of a program and everything it needs. You download one (caddy:2) or build your own from a recipe file called a Dockerfile. It never changes while it runs.
- ContainerAn image that is running. One image can start many containers, and deleting a container never touches the image — the next start is fresh.
- Port mappingA container has its own ports that nobody outside can see. -p 8080:80 connects port 8080 of your computer to port 80 inside the container, so a browser can reach it.
- VolumeThe files a container writes inside itself vanish when it is deleted. A volume, or one of your folders mounted into it, is storage that stays — your pages, your settings and Caddy's certificates live there.
Installing Docker
On a Mac or a Windows PC, install Docker Desktop from docker.com and start it once; after that the docker command works in the terminal. On a Linux server, Docker's official script installs everything in one line. Both check commands should answer before you go on.
# macOS · Windows: install Docker Desktop from docker.com and start it once
# Ubuntu / Debian server: Docker's official install script
curl -fsSL https://get.docker.com | sh
# check that both commands answer
docker --version
docker compose version
Caddy in one command
Go to a folder that holds an index.html and run the line below. The first time, Docker downloads the Caddy image; after that it starts in about a second. Open http://localhost:8080 and your page is there.
# serve this folder at http://localhost:8080 (Control-C stops it)
docker run --rm -p 8080:80 -v "$PWD":/usr/share/caddy caddy:2
docker runCreate a container from an image and start it.--rmDelete the container as soon as it stops, so stopped containers do not pile up. The image stays, so the next run is just as fast.-p 8080:80Connect port 8080 of your computer to port 80 inside the container, where Caddy listens. You choose the left number; the right one is fixed by the image.-v "$PWD":/usr/share/caddyShow the current folder ($PWD) inside the container as /usr/share/caddy — the folder the official image serves by default. Edit a file here and the next reload shows the change.caddy:2The image to run: the official Caddy image, any 2.x version. Without the :2 tag Docker takes latest, which may one day jump to a new major version under your feet.
Press Control-C to stop it. This is perfect for a quick look, but the command is long and forgotten the moment the terminal closes. For anything permanent you write it down in a file — which is the next project.
Project 1: an HTML homepage, from a folder to the internet
The whole project is three small files. index.html is the page, the Caddyfile tells Caddy what to serve, and compose.yaml tells Docker how to run Caddy. Docker Compose reads that file, so you never have to retype a long docker run command.
my-site/
├── compose.yaml
├── conf/
│ └── Caddyfile
└── site/
└── index.html
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./conf:/etc/caddy
- ./site:/srv
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
compose.yaml line by line
image: caddy:2The image to run — the same official Caddy image as before.restart: unless-stoppedIf the container crashes or the server reboots, Docker starts it again by itself, until you stop it on purpose."80:80" · "443:443"Open the machine's ports 80 (http) and 443 (https) to Caddy. Caddy needs both to obtain a certificate and to send visitors from http to https."443:443/udp"Port 443 again, but over UDP. It lets browsers use HTTP/3, the newest version of HTTP. Leaving it out is harmless; the site simply uses HTTP/2../conf:/etc/caddyYour conf folder becomes /etc/caddy, where Caddy looks for its Caddyfile. Caddy's documentation mounts the folder rather than the single file, because many editors replace a file when saving and a mounted single file would keep showing the old one../site:/srvYour site folder becomes /srv — the folder the Caddyfile serves. Change a page on disk and Caddy serves the new version at once, with no restart.caddy_data:/dataA named volume that Docker keeps for you. Caddy stores its HTTPS certificates and keys here, so they survive when the container is deleted and recreated. caddy_config beside it keeps Caddy's automatic settings the same way.
Try it on your own computer first
Write :80 in the address slot of the Caddyfile. It means "answer on port 80, whatever name is asked for" — plain http with no certificate, which is exactly right on a computer that has no domain name. Then start it and open http://localhost.
:80 {
root * /srv
file_server
}
cd my-site
docker compose up -d # start Caddy in the background
open http://localhost # or type the address into a browser
docker compose logs -f # watch requests arrive (Control-C stops watching)
Putting it on the internet
- Rent a serverAny VPS with a public IP address will do — a small, cheap one is plenty. Install Docker on it with the script above, and make sure its firewall lets ports 80 and 443 in.
- Point your domain at itWhere you bought the , add a A record: name example.com, value the server's IP address. It can take from a few minutes to a few hours before the whole internet sees the change.
- Write the real addressIn the Caddyfile, replace :80 with your domain name. That one change is what switches on automatic HTTPS: Caddy requests a certificate, installs it, renews it and sends http visitors to https.
- Copy and startCopy the folder to the server with rsync, log in with ssh and start it with docker compose up -d. Watch the log: a line saying the certificate was obtained means your site is live.
example.com {
root * /srv
file_server
encode gzip zstd
}
The address 203.0.113.10 below is a reserved example; use your own server's IP. To change the page later, edit index.html and run the same rsync again. The site folder is mounted into the container, so the new page is live on the next reload without restarting anything.
# on your computer: copy the project folder to the server
rsync -av my-site/ root@203.0.113.10:~/my-site/
# on the server: start it — Caddy fetches the HTTPS certificate by itself
ssh root@203.0.113.10
cd ~/my-site
docker compose up -d
docker compose logs -f caddy
Project 2: a React homepage
is a for building a page out of small reusable components. What matters for publishing is this: a React project is not served as it is. A build step turns it into plain HTML, CSS and JavaScript files in a dist folder — and from there it is the same job as Project 1.
You need Node.js on your computer for the commands below. Vite creates a React project that is ready to run, npm run dev shows it while you work, and npm run build writes the finished site.
# needs Node.js (nodejs.org) — create a React project with Vite
npm create vite@latest my-react -- --template react
cd my-react
npm install
npm run dev # development server at http://localhost:5173
npm run build # the finished site is written to dist/
Way A: build on your computer, copy the result
The simplest route reuses Project 1 as it is. Build, then copy only the dist folder into the server's site folder. --delete removes files from the previous build that no longer exist, so old leftovers do not pile up.
# way A: build here, copy only the result into Project 1's site folder
npm run build
rsync -av --delete dist/ root@203.0.113.10:~/my-site/site/
A React app draws every page itself from one index.html. An address like /about is not a file on disk, so without help Caddy answers 404 the moment someone reloads that page or opens a shared link to it. try_files tells Caddy: serve the file if it exists, otherwise hand over index.html and let React draw the right page.
example.com {
root * /srv
encode gzip zstd
try_files {path} /index.html
file_server
}
Way B: let Docker build it, with a two-stage Dockerfile
The sturdier route builds inside Docker, so the server — or a colleague's computer — always builds the same way, and nobody has to install Node on the server. The Dockerfile has two stages: the first builds with Node, the second keeps only the result.
# stage 1: build the React app with Node
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# stage 2: only the finished files go into the Caddy image
FROM caddy:2
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv
The Dockerfile line by line
FROM node:24-alpine AS buildStage one starts from an image that has Node.js and names this stage build, so the next stage can take files from it.COPY package*.json ./ · RUN npm ciCopy only the package list first and install exactly the versions in the lock file. Docker reuses this step while the list stays the same, so later builds skip the slow install.COPY . . · RUN npm run buildCopy the rest of the source and build. The finished site is now in /app/dist inside the build stage.FROM caddy:2Stage two starts again from a clean Caddy image. Nothing from stage one comes along unless you copy it.COPY --from=build /app/dist /srvTake only the built files from the build stage and put them where the Caddyfile serves from. Node and node_modules stay behind, so the final image stays small.
Put the Caddyfile with try_files next to the Dockerfile and use the compose.yaml below: build: . means "make the image from this folder's Dockerfile" instead of downloading one. On the server, clone the code and one command builds and starts it; after each change, pull and run the same command again.
services:
web:
build: .
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
# keep node_modules and old builds out of the image
printf 'node_modules\ndist\n' > .dockerignore
# on the server: get the code, then build and start in one step
git clone https://github.com/you/my-react.git
cd my-react
docker compose up -d --build
# after you push a change: pull it and rebuild
git pull && docker compose up -d --build
The commands you will use
| Command | What it does |
|---|---|
docker compose up -d | Start everything in compose.yaml in the background. Run it again after you change compose.yaml; only what changed is recreated. |
docker compose ps | List this project's containers and whether they are running. The first thing to check when the site does not answer. |
docker compose logs -f | Follow the logs live. Certificate problems, wrong paths and crashes all show up here first. Control-C stops the watching, not the server. |
docker compose exec -w /etc/caddy caddy caddy reload | Make Caddy reload its Caddyfile inside the running container without dropping a single connection. Use it after editing the Caddyfile. |
docker compose down | Stop and delete the containers. Named volumes are kept, so the certificates survive. Adding -v would delete them too — avoid it. |
docker compose pull | Download a newer Caddy image. Follow it with docker compose up -d to switch over to it. |
Your certificates live in the caddy_data volume. If it is deleted — with docker compose down -v, or by leaving the volume out of compose.yaml — Caddy has to request new certificates every time it starts. Certificate authorities limit how many certificates one domain can get in a week, so a few careless restarts can leave your site without HTTPS for days.
You mark this yourself. Nothing is graded here.
Mark this step as understood?
This cannot be undone yet, so mark it only when you really understand.
Marked as understood
You mark this yourself. Nothing is graded here.