AboutBlogContact
Backend EngineeringJuly 12, 1998 7 min read 425Updated: June 22, 2026

Building a Guestbook with Perl CGI: The Web Development Rite of Passage (1998)

AunimedaAunimeda
📋 Table of Contents

In 1998, every serious website had a guestbook. It was the measure of technical credibility - proof your site was dynamic, that it responded to visitors. Static HTML was for amateurs. A working guestbook meant you understood CGI.

The stack: Perl 5 on a Unix server (Apache on Linux or Solaris), the CGI specification for server-browser communication, and a flat text file for storage. No MySQL for most of us - database hosting cost $40-$80/month extra on top of basic shared hosting. Flat files worked perfectly well for a guestbook receiving dozens of visitors per day.


How CGI Actually Worked

CGI - Common Gateway Interface - was a standard for web servers to execute programs and return the output to the browser as the HTTP response. The mechanism: server receives a POST or GET request, executes the specified program (your Perl script), passes form data via environment variables and STDIN, and sends the program's STDOUT back as the HTTP response body.

The critical constraint: each CGI request spawned a new process. Every page load started Perl from scratch, loaded your script, executed it, and died. No persistent memory. No connection pooling. Under load, this was expensive - but for a guestbook getting fifty visitors per day, perfectly acceptable.


The Complete Guestbook Script

#!/usr/bin/perl
# guestbook.pl - Perl 5 CGI guestbook circa 1998
# Deploy to cgi-bin/, chmod 755, chmod 777 the data directory

use strict;
use warnings;
use CGI;
use POSIX qw(strftime);

my $GUESTBOOK_FILE = '/home/mysite/data/guestbook.txt';
my $MAX_ENTRIES    = 100;

my $cgi    = new CGI;
my $action = $cgi->param('action') || 'view';

if ($action eq 'post') {
    handle_post($cgi);
} else {
    show_guestbook($cgi);
}

# ---------------------------------------------------------------
sub handle_post {
    my ($cgi) = @_;

    my $name    = $cgi->param('name')    || '';
    my $email   = $cgi->param('email')   || '';
    my $message = $cgi->param('message') || '';
    my $website = $cgi->param('website') || '';

    # Strip HTML - no tags allowed in 1998 guestbooks
    for my $var ($name, $email, $message, $website) {
        $var =~ s/[<>&"']//g;
        $var =~ s/\n/ /g;   # collapse newlines in single-line fields
    }

    if (length($name) < 2 || length($message) < 5) {
        print_header();
        print "<p><font color='red'><b>Error:</b> Name and message are required fields.</font></p>\n";
        print "<p><a href='guestbook.pl'>Go back and try again</a></p>\n";
        print_footer();
        return;
    }

    my $timestamp = strftime('%B %d, %Y at %H:%M', localtime);

    my @entries = read_entries();
    unshift @entries, {
        name      => $name,
        email     => $email,
        message   => $message,
        website   => $website,
        timestamp => $timestamp,
    };

    # Trim to maximum
    @entries = @entries[0 .. $MAX_ENTRIES - 1] if @entries > $MAX_ENTRIES;

    write_entries(\@entries);

    # Redirect using meta refresh - HTTP 302 redirects were finicky in CGI
    print "Content-Type: text/html\n\n";
    print "<html><head><meta http-equiv='refresh' content='0;url=guestbook.pl'>\n";
    print "</head><body>Thank you! <a href='guestbook.pl'>Click here</a> if not redirected.</body></html>\n";
}

# ---------------------------------------------------------------
sub show_guestbook {
    my ($cgi) = @_;
    print_header();

    print <<'FORM';
<h2>Sign Our Guestbook</h2>
<form method="POST" action="guestbook.pl">
  <input type="hidden" name="action" value="post">
  <table border="0" cellpadding="4" cellspacing="0">
    <tr>
      <td align="right"><b>Name:</b> *</td>
      <td><input type="text" name="name" size="30" maxlength="60"></td>
    </tr>
    <tr>
      <td align="right"><b>Email:</b></td>
      <td><input type="text" name="email" size="30" maxlength="80"></td>
    </tr>
    <tr>
      <td align="right"><b>Website:</b></td>
      <td><input type="text" name="website" size="30" maxlength="100"></td>
    </tr>
    <tr>
      <td align="right" valign="top"><b>Message:</b> *</td>
      <td><textarea name="message" rows="5" cols="45"></textarea></td>
    </tr>
    <tr>
      <td></td>
      <td><input type="submit" value="Sign Guestbook"></td>
    </tr>
  </table>
</form>
<hr>
<h2>Guestbook Entries</h2>
FORM

    my @entries = read_entries();

    if (!@entries) {
        print "<p><i>No entries yet - be the first to sign!</i></p>\n";
    }

    for my $entry (@entries) {
        print "<div style='border-bottom:1px solid #cccccc; padding:6px 0; margin-bottom:6px;'>\n";
        print "<b>" . $entry->{name} . "</b>";
        if ($entry->{website}) {
            print " &mdash; <a href='" . $entry->{website} . "'>" . $entry->{website} . "</a>";
        }
        print "<br>\n";
        print "<small><i>" . $entry->{timestamp} . ":</i></small><br>\n";
        print $entry->{message} . "\n";
        print "</div>\n";
    }

    print_footer();
}

# ---------------------------------------------------------------
sub read_entries {
    my @entries;
    return @entries unless -f $GUESTBOOK_FILE;

    open(my $fh, '<', $GUESTBOOK_FILE) or die "Cannot open guestbook: $!";
    my $entry = {};
    while (my $line = <$fh>) {
        chomp $line;
        if    ($line =~ /^NAME:(.*)$/)  { $entry->{name}      = $1 }
        elsif ($line =~ /^EMAIL:(.*)$/) { $entry->{email}     = $1 }
        elsif ($line =~ /^MSG:(.*)$/)   { $entry->{message}   = $1 }
        elsif ($line =~ /^SITE:(.*)$/)  { $entry->{website}   = $1 }
        elsif ($line =~ /^TIME:(.*)$/)  { $entry->{timestamp} = $1 }
        elsif ($line eq '---') {
            push @entries, $entry if $entry->{name};
            $entry = {};
        }
    }
    close($fh);
    return @entries;
}

sub write_entries {
    my ($entries) = @_;
    open(my $fh, '>', $GUESTBOOK_FILE) or die "Cannot write guestbook: $!";
    for my $e (@$entries) {
        print $fh "NAME:"  . $e->{name}      . "\n";
        print $fh "EMAIL:" . $e->{email}     . "\n";
        print $fh "MSG:"   . $e->{message}   . "\n";
        print $fh "SITE:"  . $e->{website}   . "\n";
        print $fh "TIME:"  . $e->{timestamp} . "\n";
        print $fh "---\n";
    }
    close($fh);
}

sub print_header {
    print "Content-Type: text/html\n\n";   # The blank line after headers is mandatory
    print "<html>\n<head><title>Our Guestbook</title></head>\n";
    print "<body bgcolor='#FFFFFF' text='#000000' link='#0000CC' vlink='#551A8B'>\n";
    print "<h1>Welcome to Our Guestbook</h1>\n";
}

sub print_footer {
    print "<hr><p><small>Powered by Perl CGI &bull; Hosted on Apache/Linux</small></p>\n";
    print "</body></html>\n";
}

Deployment in 1998: FTP, Telnet, chmod

Getting a CGI script running on a shared Unix host in 1998 required:

  1. FTP the script into the cgi-bin/ directory - web servers only executed CGI from this path
  2. Telnet into the server: telnet myhost.com 23
  3. Set executable permissions: chmod 755 guestbook.pl
  4. Create the data file and make it world-writable: chmod 777 /home/mysite/data/

That chmod 777 was accepted practice. It was also a security hole - any other user on the shared server could overwrite your data. We knew it was not ideal. We did it anyway, because the alternative required root access to set up proper group permissions, and shared hosting providers gave you neither root nor flexibility.

The most common error: 500 Internal Server Error. Almost always one of three causes:

# Diagnosis sequence every 1998 Perl developer knew cold:

# 1. Wrong Perl interpreter path on shebang line
head -1 guestbook.pl
# Should match: which perl

# 2. Windows line endings (CRLF) on a Unix server - kills the shebang
file guestbook.pl
# If it says "CRLF line terminators", strip them:
perl -pi -e 's/\r\n/\n/g' guestbook.pl

# 3. Permissions wrong
ls -la cgi-bin/guestbook.pl
# Should show: -rwxr-xr-x

# 4. Read the Apache error log (if you had SSH access)
tail -20 /var/log/apache/error.log

What Stopped Spam: Very Little

Spam protection in a 1998 guestbook was minimal:

# Basic rate limiting: check if this IP posted in the last 60 seconds
# Stored in a lock file - crude but effective for 1998 traffic levels

my $ip       = $ENV{REMOTE_ADDR} || 'unknown';
my $lockfile = "/tmp/gb_lock_${ip}";

if (-f $lockfile && (time() - (stat($lockfile))[9]) < 60) {
    print_header();
    print "<p>Please wait before posting again.</p>\n";
    print_footer();
    exit;
}

# Create/update lock file
open(my $lf, '>', $lockfile) or warn "Cannot create lock: $!";
close($lf);

This failed as soon as anyone had a dynamic IP or used a proxy, which in 1998 was everyone on AOL dial-up. We were not doing much better than nothing. Spam was a manageable problem in 1998 because most spammers had not yet automated web form submission. By 2001, guestbook spam would become severe enough to drive the adoption of CAPTCHA.


Why PHP Displaced Perl for Web Work

By 2000, Perl CGI was rapidly losing ground to PHP 4. The reason was not that PHP was more capable - Perl was arguably more powerful. The reason was deployment friction.

A PHP page:

  • Sits next to your HTML files in public_html/
  • Gets executed automatically by Apache's mod_php
  • Requires no cgi-bin/ directory
  • Requires no chmod 755
  • Has the Perl path hardcoded nowhere

The Perl guestbook above required understanding Unix file permissions, the CGI protocol, and Perl's CGI module interface before you could get "Hello World" to the browser. PHP let you write <?php echo "Hello"; ?> in an HTML file and have it work immediately.

That simplicity won the web application market. Perl kept Unix administration, text processing, and bioinformatics. The web went to PHP - and then in 2005, to Rails and the era after that.

The Perl CGI guestbook was the tutorial that taught a generation how web servers communicate with programs. The patterns it demonstrated - parsing form data, generating HTML responses, reading and writing persistent storage - remain exactly the same in every modern web framework, just with the machinery hidden.


Aunimeda builds production-grade backend systems - APIs, microservices, real-time applications, and system integrations.

Contact us for backend engineering services. See also: Custom Software Development, Web Development

Read Also

Node.js: Ryan Dahl's 45-Minute Talk That Rewrote Backend Development (2009)aunimeda
Backend Engineering

Node.js: Ryan Dahl's 45-Minute Talk That Rewrote Backend Development (2009)

On November 8, 2009, Ryan Dahl presented Node.js at JSConf EU in Berlin. He showed JavaScript running on the server with non-blocking I/O. The audience sat in silence, then gave a standing ovation. Within three years, Node.js had more packages than any other programming language ecosystem. This is what he built and why it worked.

Google Chrome and V8: The Day JavaScript Got Fast (2008)aunimeda
Backend Engineering

Google Chrome and V8: The Day JavaScript Got Fast (2008)

On September 2, 2008, Google released Chrome 0.2 with the V8 JavaScript engine. V8 compiled JavaScript directly to native machine code. Benchmarks showed it 10-50x faster than IE7. Within eighteen months, every browser had raced to match it. JavaScript performance went from a constant limitation to a solved problem.

The iPhone Problem: Every Website Broke on June 29, 2007aunimeda
Backend Engineering

The iPhone Problem: Every Website Broke on June 29, 2007

On June 29, 2007, Apple released the iPhone. It ran a real browser - Safari on WebKit - not a WAP-stripped proxy. For the first time, users expected actual websites on mobile. And virtually every website on the internet looked terrible. This is what we did about it.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles