SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

What Is a Web Server? How It Works on a VPS

A web server is the program that answers HTTP requests, not the machine. Run one with Python and one with nginx on localhost, then read the real headers.

What is a web server?

A web server is the program that listens on a network port and answers HTTP (Hypertext Transfer Protocol) requests with HTTP responses. It is software, not the machine it runs on. The HTTP specification, RFC 9110, defines it the same way: an HTTP server is "a program that accepts connections in order to service HTTP requests by sending HTTP responses." Your VPS is the computer. nginx, Apache, Caddy or a one-line Python command is the web server.

People often say "the web server is down" when they mean the whole machine. That mix-up costs time when something breaks. If the VPS answers SSH but the browser shows nothing, the machine is fine. The program has stopped, or it listens on a different port, or a firewall blocks the port. Every check in this guide asks the program directly, on the same machine, with curl to localhost. After the packages are installed, nothing here needs a public IP or outbound internet access.

What happens when a request arrives

Every web server does the same four steps, whatever it is called:

  1. A client opens a TCP (Transmission Control Protocol) connection to an IP address and a port.
  2. The client sends a request line, such as GET /index.html HTTP/1.1, followed by header lines.
  3. The web server maps the request to something: a file on disk, or another program.
  4. The server sends back a status line such as 200 OK and its response headers, then usually a body.

The wire format, the methods and the status codes are covered in what HTTP is and how a request and a response look. This guide stays on the program side: what listens, where it reads files from, and how it hands work to other programs.

Start the smallest possible web server

Python ships an HTTP server module. One command turns a directory into a website. First, create a directory with one page in it:

mkdir -p ~/site
echo '<h1>Hello from a web server</h1>' > ~/site/index.html
python3 --version

Now start the server in the background. The module listens on port 8000 by default, and the command below names the port anyway so you can see it. The --bind 127.0.0.1 option matters. Without it, the server binds to all interfaces, which means anyone who can reach your VPS on port 8000 can read the directory.

python3 -m http.server 8000 --bind 127.0.0.1 --directory ~/site > /tmp/http-server.log 2>&1 &
curl -i http://127.0.0.1:8000/

curl -i prints the response headers, then the body. Read what your terminal shows, line by line:

  • The first line is the status line, and it should carry 200.
  • The Server line starts with SimpleHTTP/, followed by version strings. This is how the program names itself.
  • Content-type is guessed from the .html extension. Content-Length is the file size in bytes, and Last-Modified is the time the file was last changed.
  • After a blank line comes the body. It is the exact content of index.html.

You asked for / and got index.html because the path maps to a directory. For a directory, the Python handler looks for an index page first, and only generates a file listing when there is none. Now ask for a file that does not exist:

curl -i http://127.0.0.1:8000/missing.html

The status line now carries 404. The program is still running and still answering. A 404 means the server looked and found no file at that path. That is a different problem from getting no answer at all.

One note on curl flags. curl -I (capital I) sends a HEAD request and prints only headers. A server may answer HEAD slightly differently from GET, so use -i when you want to see the response a browser would get.

The Python docs are direct about this tool: http.server is not recommended for production, and it only implements basic security checks. Use it to test and to learn. Leave it running for now, because it plays the application in the reverse proxy demo below.

Install a production web server: nginx

nginx is a common choice on a VPS. Install it from the Ubuntu archive:

sudo apt update && sudo apt install -y nginx
nginx -v
sudo nginx -t

nginx -v prints the version your distribution packaged. On Ubuntu 24.04 the package is the 1.24.0 line. nginx.org listed mainline 1.31.7 and stable 1.30.6 on 20 October 2026, so the packaged version is older than the newest release. That is normal for a distribution package, and it is fine for learning.

nginx -t checks the configuration syntax, then tries to open every file the configuration names. Its last line should say the test is successful. Run it before every start or reload. A broken config caught here never reaches your visitors.

Start nginx and make it start at boot:

sudo systemctl enable --now nginx
systemctl is-active nginx

is-active should print active. On a system without systemd, such as a minimal container, run sudo nginx instead. nginx becomes a background daemon by default, so the command returns to your prompt while the server keeps running.

Now send it a request:

curl -i http://localhost/

This URL has no port, so curl uses port 80, the default for http://. The body is the default home page that the package sets up at install. Compare the Server line with the one Python sent. Each program names itself, which is a quick way to learn which program answered when several run on one box.

Where nginx gets its answers

Three commands show what nginx is really doing, instead of what a doc says the defaults are:

sudo nginx -T 2>/dev/null | grep -E '^[[:space:]]*(user|listen|root|index)[[:space:]]'
sudo ss -ltnp | grep nginx
ps -o user,pid,cmd -C nginx

nginx -T prints the whole configuration as nginx reads it, including every included file. The grep keeps four kinds of lines:

  • listen names the address and port each server block accepts connections on.
  • root is the directory on disk. nginx adds the request path to it to find a file.
  • index names the file served when the path is a directory. The built-in default is index.html.
  • user names the account the worker processes run as.

The packaged config sets its own values for these, so trust this output over any default listed in the documentation.

ss -ltnp lists listening TCP sockets, with the process that owns each one. You should see nginx on port 80. ps shows one master process line and one or more worker process lines. The master reads the configuration and manages the workers. The workers handle the requests. Compare the USER column on those lines with the user line from nginx -T.

Static content versus dynamic content

Static content is a file that already exists on disk. Every request for it gets the same bytes until someone edits the file. Both demos so far served static content. The web server's job is a mapping: with root /srv/site, a request for /about.html reads /srv/site/about.html. Pages, images, CSS and JavaScript files are usually static.

Dynamic content is built by code at the moment of the request. A login page, a search result and an API reply are dynamic, because the answer depends on who asks and on data that changes. nginx does not run your Python or PHP code itself. It passes the request to an application server, a separate program that runs your code and returns a response. Gunicorn plays this role for Python apps and speaks HTTP. PHP-FPM plays it for PHP and speaks FastCGI instead. A full example of that pairing is deploying Django with Gunicorn behind nginx on a VPS.

If your site is only files, you need no application server at all. That is the simplest setup, and self-hosting a website on your own VPS starts from exactly there.

Where a reverse proxy fits

RFC 9110 calls a reverse proxy a "gateway": an intermediary that acts as the origin server for the client, but forwards requests inbound to another server. On a VPS the usual shape is simple. nginx owns ports 80 and 443 and faces the internet. It serves static files itself, and passes the other requests to an application server on a localhost port. The application server never faces the internet directly.

The Python server from earlier is still running on port 8000. Use it as the application. On Ubuntu, the packaged nginx.conf includes every .conf file in /etc/nginx/conf.d/. Confirm that with sudo nginx -T 2>/dev/null | grep conf.d before you continue.

sudo tee /etc/nginx/conf.d/proxy-demo.conf > /dev/null <<'EOF'
server {
    listen 127.0.0.1:8080;
    location / {
        proxy_pass http://127.0.0.1:8000;
    }
}
EOF
sudo nginx -t && sudo nginx -s reload
curl -i http://127.0.0.1:8080/

The body is your index.html, which only the Python server has. The Server line now names nginx, not SimpleHTTP. That happens because nginx does not pass the Server and Date headers from the proxied server to the client by default. It sends its own. So the client sees one web server, while the content came from another program.

One default here surprises people. nginx does not forward the client's Host header to the backend. It sends Host set to the backend address written in proxy_pass. An application that builds links from Host will then build them with 127.0.0.1:8000 in them. Real configs fix this with proxy_set_header Host $host;.

Now stop the application and ask nginx again:

pkill -f 'http.server 8000'
curl -i http://127.0.0.1:8080/

The status line now carries 502, which means Bad Gateway. nginx is running and accepted your connection, but the program behind it did not answer. When a real site shows 502, check the application server first, not nginx. The nginx error log says why the backend connection failed. Find its path with sudo nginx -T 2>/dev/null | grep error_log.

Remove the demo when you are done:

sudo rm /etc/nginx/conf.d/proxy-demo.conf
sudo nginx -t && sudo nginx -s reload

nginx is one of several programs that can do this job. nginx vs Caddy vs Traefik as a reverse proxy compares them, and Apache as a reverse proxy with mod_proxy covers the same idea on Apache.

Why ports 80 and 443 matter

A URL with no port number uses a default port. RFC 9110 sets TCP port 80 for http:// and TCP port 443 for https://, and IANA (the Internet Assigned Numbers Authority) registers them for those services. This is why curl http://localhost/ reached nginx with no port, while the Python server needed :8000 in the URL. A public site on any other port forces every visitor to type the port, so real sites listen on 80 and 443.

Those two ports are also privileged on Linux. The kernel treats every port below net.ipv4.ip_unprivileged_port_start as privileged, and the default value is 1024. Binding a privileged port needs root or the CAP_NET_BIND_SERVICE capability. That is why the Python demo used port 8000 as a normal user. Check the value on your system:

sysctl net.ipv4.ip_unprivileged_port_start

With the default of 1024, python3 -m http.server 80 --bind 127.0.0.1 run as a normal user fails with Permission denied. Some container runtimes lower the value, so the same command can succeed inside a container. nginx follows the same rule: when a server block has no listen line, nginx uses port 80 if it runs as root, and port 8000 otherwise.

Port 443 means HTTPS, which is HTTP inside TLS (Transport Layer Security). nginx needs listen 443 ssl plus the paths to a certificate and its private key, so HTTPS cannot be shown on localhost without a certificate. What changes when your server moves from HTTP to HTTPS explains the difference, and getting a Let's Encrypt certificate for nginx with Certbot is the setup itself.

When curl gets no answer, what is wrong?

Each symptom points at a different layer, so read the exact message before you change anything.

  • Connection refused from curl: nothing listens on that address and port. The program has stopped, or it listens on another port or address. sudo ss -ltnp shows what does listen.
  • Address already in use when a server starts: another program already holds that port. Two programs cannot listen on the same address and port. Run sudo ss -ltnp 'sport = :80' to see which one has it.
  • A 404 status: the server is running, but the path maps to no file. Compare the request path with the root line from nginx -T.
  • A 502 status from nginx: nginx is fine, and the application behind it did not answer. Start or fix the application server.

FAQ

Is a web server the same thing as a VPS?

No. A VPS is a virtual machine: the computer. A web server is a program running on that computer, such as nginx, Apache or Caddy, that listens on a port and answers HTTP requests. One VPS can run several web servers on different ports, and it can run none. When a site is down but SSH still works, the machine is up and the problem is the web server program.

Do I need nginx if my app already speaks HTTP?

Your app can answer HTTP by itself, but on a VPS it usually sits behind a reverse proxy such as nginx. The proxy owns ports 80 and 443, handles the TLS certificate, and serves static files. The app listens on a localhost port and never faces the internet. Python's own http.server documentation says it is not recommended for production, which is a good reason to keep test servers on 127.0.0.1.

Why does my server work on port 8000 but not on port 80?

On Linux, ports below 1024 are privileged by default. Binding one needs root or the CAP_NET_BIND_SERVICE capability, so a program started by a normal user fails on port 80 with Permission denied. Check the limit with sysctl net.ipv4.ip_unprivileged_port_start. Packaged nginx avoids the problem because it is started as root, and it runs its worker processes as the user named in its config.

Why does nginx return 502 Bad Gateway?

A 502 from nginx means nginx accepted the request but could not get a response from the program it proxies to. The application server has stopped, or proxy_pass points at the wrong address or port. Check that the backend is listening with sudo ss -ltnp, then read the nginx error log, whose path sudo nginx -T 2>/dev/null | grep error_log prints.