While separation of privilege is ideal, a lot can be done simply by discarding privileges. UNIX provides the setuid() family of system calls, which allows a process launched as root to run as a different user.
A web server needs to run as root because it needs to bind to port 80 and access files in every user's public_html directory. Once it has bound to port 80, however, it doesn't need to run as root, and can discard root privileges. It still needs a mechanism for accessing each user's public_html directory, however. One solution is to have each user make his or her files accessible by the web server group. Another might be to fork() off a child process for each user, which runs as that user and handles access to files in the user's directory.
Security can be improved slightly by use of the chroot() system call, which changes the root directory (as seen by the process) to be the specified directory. Any files outside this directory are invisible, although ones that have already been opened still can be accessed. That fact is important, because you can keep shared libraries and even the program executable and configuration files outside the chroot jail.
The root user can easily escape from a chroot by creating a new device node for a hard disk and mounting it inside the chroot (or even accessing it directly). Therefore, it's important to drop root privileges as soon as you enter the chroot.
It's much easier to use chroot() if it's designed into the application. A chroot command is also available, which runs a process in a chroot environment, but this approach has two problems. First, it invokes the chroot before running the process, so the program and all the libraries it needs have to be in the jail. Secondly, it must run as root, so it needs something inside the chroot capable of dropping privilege. A common solution is to put the su command inside the chroot. Once you've put such a lot of code inside the chroot, however, it starts to look a lot like the outside environment.