Web Server
Twenty lines of that a will talk to.
A web server is just a program that waits for a request and sends a response. Build one from 20 lines of bash and netcat, then move on to Caddy with HTTPS.
A web server is a program that answers
Strip away the and the logos and a web server is one 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 , 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 browser connects and sends a few lines of text. The first line says which method and which path it wants, for example a of the path /.
- DecideThe server works out what to send: read a file from , run some , query a . Our example always sends the same file, which is the only reason it fits in twenty lines.
- Respond and closeIt writes a , a few , one and the content, then closes the connection and loops round for the next visitor.
Every server you have heard of — , , , the program () behind this very site — does the four steps above. The difference is that they do it for thousands of people at once, safely, over . The shape is identical.
A web server in one shell script
Here is a complete web server written in . It opens a , reads the , it, and sends back as a proper . Twenty lines, no , and a browser cannot tell the difference.
Two do the heavy lifting. (nc) speaks the , and makes a 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 file. It appears in the folder like an ordinary file, but nothing is ever stored in it — it only passes 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 an existing file.read -r method path _Reads the first line of the and splits it into method, and the rest. Everything after it — the browser's — is ignored by this server.echo "[$(date)] $method $path" >&2Prints a log line. The sends it to so it appears on your instead of being sent to the browser as part of the page.index=$(cat index.html)Reads index.html from the into a . 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 , the content type, a header saying the connection ends here, then a and the page. The blank line is what separates headers from content, and demands carriage-return-and-newline on every one of them.response < "$PIPE" | nc -l "$PORT" > "$PIPE"The trick that makes it work. nc takes the response written by response before the | and sends it to the browser, and writes the browser's request into the FIFO file ($PIPE). response reads the request from that FIFO, so the two halves feed each other.while true; do … doneThe . nc handles one connection and exits, so without this the server would answer a single visitor and stop.
The request has to reach the 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 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 , and delete the pipe file afterwards if it is still there. On the nc command may need an extra -p before the port; on the shipped with the system works as written.
Learn the shell commands used hereWhat actually travels down the wire
Open the connection and you find you could have typed yourself. This is the whole reason the shell server 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 ( means fetch, means send something), the path, and the .
- says which site is wanted. One server can hold hundreds of domains on one address, and this is how it tells them apart.
- The rest are the browser introducing itself: what 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 : the protocol version and a saying how it went. means the succeeded.
- tells the browser how to read what follows. Send with the wrong type and the browser will show your as text instead of a page.
- The part is not optional in practice. Without it, any non-English text is likely to arrive as garbled characters.
- Then one , 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 contains what was asked for. Codes in the all mean some flavour of that. |
301 Moved Permanently | It moved, permanently. The new address is in the and the browser follows it automatically — this is how a site sends visitors from to https (some, like , use , which means the same). |
404 Not Found | The server is fine but that path does not exist. Codes in the mean the problem is in the request. |
500 Internal Server Error | The server broke while handling a perfectly good request. Codes in the mean the problem is yours to fix, and the details are in the . |
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. 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 — there is no and no second page.
- It sends everything as . Images, stylesheets and downloads all need their own content type, and this server has exactly one.
- No . Everything travels in the clear, so any in between can read it or change it on the way.
- No safety at all. It never checks the size of a , 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
is a modern web server in a single . 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 what the shell cannot (many visitors at once, , by path).
Its headline feature is . Give it a real and it obtains a , installs it, renews it before it expires, and 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 — add Caddy's own package list first (official steps)
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy
# with Docker (covered in the Docker tab): serve this folder on port 8080
docker run -p 8080:80 -v "$PWD":/usr/share/caddy caddy:2
Serving a folder in one command
No needed. Go to the folder holding your and run one command — this is the fastest way to show a page to someone on the same .
# 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 . It is a 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. Even with , Caddy uses HTTPS with a certificate it makes itself; for plain http, write http://localhost or :80 instead.root * /var/www/htmlWhere the files live on . The star means it applies to every path of this site.file_serverActually serve those files. Without this line Caddy answers the with nothing.encode gzip zstdbefore sending them. One word, and pages arrive noticeably faster on slow connections.
Caddy asks for a free certificate. For that, the domain's must point at your machine, and ports 80 and 443 must be reachable from outside. If another in front answers those ports instead, the check fails and the certificate never arrives. That is the 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 . A is a server that takes the public request and hands it to that application, then returns its answer. One line does it.
example.com {
reverse_proxy 127.0.0.1:8080
}
files.example.com {
root * /srv/files
file_server browse
}
This is also how one machine many sites. Only one 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 to the . 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 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 into the standard in place. A Caddyfile works with any indentation, but Caddy warns when the formatting is off. |
caddy validate | Check the configuration for mistakes without starting anything. Run it before you reload a live server. |
On the SONUB server, one front Caddy holds ports 80 and 443 and splits traffic by domain name: the main site goes to the app , and the download goes to a small Caddy container that serves files. Each site is one more block like the example above, and there is no second program fighting for the port.
Docker: Caddy in a box, then a real site online
packs a , and everything it needs to run, into a single . Libraries, settings and even a small go in. Then it 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.
is already a single file, so why bother? Because a real server 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 or a PC, install from docker.com and start it once; after that the docker command works in the . On a 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 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 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 . Without the :2 Docker takes , which may one day jump to a new major version under your feet.
Press 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 tells Caddy what to serve, and tells Docker how to run Caddy. Docker Compose reads that file, so you never have to retype a long 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 () and 443 (https) to Caddy. Caddy needs both to obtain a and to send visitors from http to https."443:443/udp"Port 443 again, but over . It lets browsers use . Leaving it out is harmless; the site simply uses HTTP/2../conf:/etc/caddyYour conf folder becomes /caddy. That is where Caddy looks for its Caddyfile. Caddy's documentation mounts the folder, not the single file. Many editors replace a file when they save it, and a single mounted file would keep showing the old one../site:/srvYour site folder becomes /srv — the folder the Caddyfile serves. Change a page on and Caddy serves the new version at once, with no restart.caddy_data:/dataA named 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 . 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 with a will do — a small, cheap one is plenty. Install Docker on it with the script above, and make sure its lets ports 80 and 443 in.
- Point your domain at itWhere you bought the domain name, add a : name , value the server's . 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 : Caddy a certificate, installs it, renews it and sends http visitors to https.
- Copy and startCopy the folder to the server with (a command that copies only what changed), in with ssh (a command that opens a terminal on another computer) and start it with -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 . What matters for publishing is this: a React project is not served as it is. A turns it into plain , and JavaScript files in a dist folder — and from there it is the same job as Project 1.
You need on your computer for the commands below. creates a React project that is ready to run. dev shows it while you work, and npm run build makes 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 the moment someone reloads that page or opens a shared link to it. 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 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 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 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:
# on your computer, inside my-react: keep node_modules and old builds out of the image
printf 'node_modules\ndist\n' > .dockerignore
git add .dockerignore && git commit -m "Add .dockerignore" && git push
# 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 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 are stored in the caddy_data volume. If it is deleted, Caddy has to request new certificates every time it starts. It gets deleted by -v, or by leaving the volume out of compose.yaml. 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.