intertwingly

It’s just data

Bringing CGI Back from the Dead


The showcase application has a deployment problem. Every time I add a new studio location or event, I need to redeploy the entire application across all regions. This triggers a cascade of tasks: updating maps, regenerating htpasswd files, prerendering indexes, and producing new navigator configuration. The process takes minutes and causes momentary downtime.

For most operations, this heavyweight process is overkill. We're redeploying an entire Rails application just to fetch an updated database from S3 and update a password file.

The solution? Go back to 1993 and embrace CGI (Common Gateway Interface).

The Current State: Deploy for Everything

Right now, the showcase application runs on Fly.io across multiple regions using Navigator, my custom Go-based reverse proxy. When I need to make changes, the workflow looks like this:

Today's Deployment Flow

  1. Make changes (add new studio, update event, change password)
  2. Deploy application (fly deploy --strategy=rolling)
  3. For each region:
    • Shut down old instance
    • Start new instance in maintenance mode
    • Run initialization hook (script/nav_initialization.rb)
    • Sync databases from S3/Tigris (--index-only)
    • Update htpasswd file
    • Run prerender (generate static HTML)
    • Generate navigator configuration
    • Reload new configuration

This entire process takes 3-5 minutes and causes brief downtime as instances cycle. For simple operations like adding a password or updating an index, it's absurdly heavyweight.

The Exception: Password Updates

There's already one escape hatch from this process. The event#index_update route is a special case:

def index_update
  # Run the sync script with --index-only option
  script_path = Rails.root.join('script', 'sync_databases_s3.rb')
  stdout, stderr, status = Open3.capture3('ruby', script_path.to_s, '--index-only')

  User.update_htpasswd

  # Return plain text response
  if status.success?
    render plain: output, status: :ok
  else
    render plain: output, status: :internal_server_error
  end
end

This endpoint lets me update the index database and htpasswd file without redeployment. It's fast (seconds), has no downtime, and works perfectly. But it has limitations:

The question: Can we generalize this pattern?

The Plan: Smart CGI Scripts

The vision is to replace the entire heavyweight deployment process with a single intelligent CGI script that:

  1. Fetches the index database from Tigris/S3
  2. Compares with current state (what changed?)
  3. Performs only necessary operations:
    • New studio locations → regenerate region maps
    • Password changes → update htpasswd
    • New events → prerender those specific indexes
    • Configuration changes → regenerate navigator config
  4. Triggers navigator reload (SIGHUP or config reload)
  5. Returns immediately with operation status

Instead of a 5-minute redeployment that restarts everything, we get a 10-second targeted update with zero downtime.

Benefits

Speed: Operations complete in seconds instead of minutes

Granularity: Only perform work that's actually needed

No Downtime: Running instances never restart

Simplicity: One script instead of complex deployment orchestration

Visibility: Direct output showing exactly what changed

Step One: CGI Support in Navigator

To make this plan work, Navigator needs to support CGI scripts. Not the neutered version you might find in a modern web framework—real CGI with the features that made it powerful in 1993:

Why CGI in 2025?

CGI has a reputation problem. It's "old" and "slow" compared to modern alternatives. But for this use case, it's perfect:

Simplicity: No web framework needed—just a script that reads stdin, writes stdout

Isolation: Each request runs in a fresh process (perfect for admin tasks)

Language Agnostic: Write in Ruby, Python, shell—whatever makes sense

Standard Protocol: RFC 3875 from 1997 still works perfectly

Resource Efficiency: No persistent process for infrequent operations

The performance "problem" with CGI is that it starts a new process for each request. For high-frequency endpoints serving HTML pages, that's a real concern. But for admin operations that run a few times a week? The fork+exec overhead is noise.

Implementation in Navigator

I added full CGI support to Navigator with these features:

1. Configuration

server:
  cgi_scripts:
    - path: /showcase/index_update
      script: /rails/script/update_configuration.rb
      method: POST
      user: rails
      group: rails
      allowed_users:
        - admin
      timeout: 10m
      reload_config: config/navigator.yml
      env:
        RAILS_DB_VOLUME: /mnt/db
        RAILS_ENV: production

2. User Switching (Unix only)

Scripts can run as different users for security isolation:

if h.User != "" {
    cred, err := process.GetUserCredentials(h.User, h.Group)
    if err != nil {
        // Handle error
    }
    if cred != nil {
        h.setProcessCredentials(cmd, cred)
    }
}

This requires Navigator to run as root, then drop privileges to the specified user. Same pattern used for Rails tenant processes.

3. Fine-Grained Access Control

The allowed_users field provides authorization beyond authentication:

cgi_scripts:
  # Admin-only: Database operations
  - path: /admin/db_sync
    allowed_users:
      - admin

  # Operators can restart services
  - path: /admin/restart
    allowed_users:
      - admin
      - operator
      - oncall

  # All authenticated users can check status
  - path: /admin/status
    # No allowed_users = all authenticated users

Empty allowed_users means any authenticated user can access. With users specified, only those usernames get access (403 Forbidden for others).

4. Smart Configuration Reload

This is the key innovation. The reload_config field tells Navigator to reload configuration after the script completes—but only when necessary:

func ShouldReloadConfig(reloadConfigPath, currentConfigPath string, startTime time.Time) ReloadDecision {
    if reloadConfigPath == "" {
        return ReloadDecision{ShouldReload: false}
    }

    // Different config file? Reload.
    if reloadConfigPath != currentConfigPath {
        return ReloadDecision{
            ShouldReload:  true,
            Reason:        "different config file",
            NewConfigFile: reloadConfigPath,
        }
    }

    // Config modified during script execution? Reload.
    info, err := os.Stat(reloadConfigPath)
    if err != nil {
        return ReloadDecision{ShouldReload: false}
    }

    if info.ModTime().After(startTime) {
        return ReloadDecision{
            ShouldReload:  true,
            Reason:        "config file modified",
            NewConfigFile: reloadConfigPath,
        }
    }

    return ReloadDecision{ShouldReload: false}
}

Navigator only reloads when:

This prevents unnecessary reloads and makes the system efficient.

5. Standard CGI Environment

Navigator sets all standard CGI/1.1 environment variables (RFC 3875):

cgiEnv := map[string]string{
    "GATEWAY_INTERFACE": "CGI/1.1",
    "SERVER_PROTOCOL":   r.Proto,
    "SERVER_SOFTWARE":   "Navigator",
    "REQUEST_METHOD":    r.Method,
    "QUERY_STRING":      r.URL.RawQuery,
    "SCRIPT_NAME":       r.URL.Path,
    "SERVER_NAME":       host,
    "REMOTE_ADDR":       r.RemoteAddr,
    "CONTENT_TYPE":      r.Header.Get("Content-Type"),
    "CONTENT_LENGTH":    r.Header.Get("Content-Length"),
}

// Plus HTTP_* variables for all headers

This means scripts can use standard CGI practices that have worked for 30+ years.

Request Flow

When a CGI request comes in:

  1. Authentication - Navigator checks HTTP Basic Auth
  2. Authorization - If allowed_users is set, verify username is in list
  3. Script Execution - Fork process, set credentials, run script
  4. Parse Response - Read CGI headers (Status, Content-Type) and body
  5. Check Reload - Did config file change during execution?
  6. Trigger Reload - Send signal to reload configuration if needed

The entire implementation is about 330 lines of Go. It handles timeouts, user switching, environment setup, response parsing, and configuration reload—all the pieces needed for production use.

Testing

The implementation includes comprehensive tests:

func TestHandler_AccessControl(t *testing.T) {
    tests := []struct {
        name           string
        allowedUsers   []string
        username       string
        wantStatus     int
    }{
        {
            name:         "No allowed_users - all authenticated users allowed",
            allowedUsers: nil,
            username:     "testuser",
            wantStatus:   200,
        },
        {
            name:         "User in allowed list - access granted",
            allowedUsers: []string{"alice", "bob"},
            username:     "bob",
            wantStatus:   200,
        },
        {
            name:         "User not in allowed list - access denied",
            allowedUsers: []string{"alice", "bob"},
            username:     "charlie",
            wantStatus:   403,
        },
        // ... more test cases
    }
}

All tests pass with 100% coverage.

What's Next

With CGI support in Navigator, the pieces are in place to build the intelligent configuration script. The next steps:

  1. Write the smart CGI script (script/update_configuration.rb)

    • Fetch index.sqlite3 from Tigris
    • Compare with current state
    • Detect what changed (studios, events, passwords, etc.)
    • Perform only necessary operations
    • Generate new navigator config if needed
  2. Add admin interface

    • Button to trigger updates
    • Show real-time output
    • Display what operations were performed
  3. Replace deployment workflow

    • Deploy only for code changes
    • Use CGI script for configuration changes
    • Measure actual time savings
  4. Monitor and refine

    • Track how often each operation runs
    • Optimize slow operations
    • Add more granular detection

Why This Matters

This isn't just about saving a few minutes on deployments. It's about building systems that are responsive to change.

In 2025, we have:

And sometimes, all you really need is a script that runs when you press a button.

CGI was designed in 1993 to solve exactly this problem: execute a program in response to an HTTP request. It's simple, it works, and it's perfect for infrequent administrative operations.

By adding modern features—user switching, access control, smart reloading—we get the best of both worlds: the simplicity of CGI with the security and integration of a modern system.

Try It Yourself

The CGI implementation is in Navigator v0.16.0+. Full documentation and examples are available at Navigator's documentation site.

Key files:

The code is straightforward Go—read through it and you'll see there's no magic. Just careful attention to the CGI/1.1 specification and thoughtful integration with Navigator's existing features.

Lessons Learned

1. Old Protocols Are Often Good Protocols

CGI/1.1 (RFC 3875, 1997) works perfectly in 2025. The specification is clear, implementations are simple, and the protocol does exactly what it needs to do—nothing more, nothing less.

2. Process Isolation Has Value

Running each request in a fresh process isn't always a performance problem. For admin operations, it's a feature: clean state, no resource leaks, deterministic behavior.

3. Smart Reloading Beats Manual Reloading

Instead of making users remember to reload configuration, detect when it's needed. Navigator's reload logic only triggers when the config file actually changed during execution—no wasted work.

4. Access Control Should Be Fine-Grained

Authentication (who are you?) and authorization (what can you do?) are different concerns. The allowed_users feature lets you restrict sensitive operations without complex role systems.

5. Documentation Matters

The Navigator CGI implementation includes:

Good documentation turns a feature from "possible" to "practical."

Looking Forward

This is the first step toward a more responsive showcase deployment system. Future posts will cover:

But the foundation is in place: Navigator can execute CGI scripts with modern security, access control, and smart configuration reloading. Sometimes the best solution involves bringing back the old ways—with a few modern improvements.

The 1990s web knew what it was doing. CGI worked then, and it still works now.