When I set out to build the backend for BPL 2026 (Bangladesh Pro League) – a national-scale esports tournament registration system – I had one hard constraint: the entire thing had to run on a 512 MB RAM VPS. Not because I wanted to prove a point, but because that was the budget. The system needed to handle team registrations, file uploads, a full admin dashboard, SMTP email blasts, group management for 2,000+ teams, and a live tournament bracket system. All of it.

This is the story of how I made it work, and how it ultimately handled over 1 million requests without breaking a sweat.


The Problem: Everything is Against You

A typical tournament platform would reach for PostgreSQL, Redis, a task queue, maybe a separate file storage service. Each of those alone could eat 128 MB. On a 512 MB box, there is no room for a traditional stack. The Go binary itself, the OS kernel, Apache, systemd, sshd – they all need memory too.

I needed a backend that:

  1. Has zero external dependencies (no database, no Redis, no message broker)
  2. Keeps a constant, predictable memory footprint regardless of traffic spikes
  3. Serves a heavy single-page frontend to tens of thousands of concurrent visitors
  4. Handles multipart file uploads (team logos, player photos) without buffering entire files in memory
  5. Sends thousands of emails without blocking request handlers

The answer was to treat the VPS as a constrained embedded system and design every subsystem around that constraint.


Decision 1: Kill the Database

The single most impactful decision was replacing a database with append-only NDJSON flat files. Every team registration is serialized to a single JSON line and appended to registrations.ndjson:

// storage.go -- append-only NDJSON with mutex + OS-level file locking
func (s *Storage) Append(reg *TeamRegistration) error {
    data, err := json.Marshal(reg)
    if err != nil {
        return err
    }
    data = append(data, '\n')

    s.mu.Lock()
    defer s.mu.Unlock()

    // Acquire OS-level file lock (advisory on Linux).
    if err := lockFile(s.file); err != nil {
        s.logger.Warn("file lock failed, proceeding with mutex only", "error", err)
    }
    defer unlockFile(s.file)

    _, err = s.file.Write(data)
    if err != nil {
        return err
    }
    return s.file.Sync()
}

Why this works on 512 MB:

  • No database process eating 100-200 MB of resident memory
  • No connection pooling overhead – it is just a file handle
  • Writes are sequential and fsync’d – crash-safe by design
  • The file is opened once at startup and the handle is reused for the entire process lifetime

On startup, the system does a single sequential scan of the NDJSON file to rebuild all in-memory indexes. A bufio.Scanner with a capped 1 MB line buffer means even the rebuild phase has bounded memory:

scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 64KB initial, 1MB max

This approach means the hot path (registration, login, stats) never touches disk for reads. Disk is only involved for writes, which are append-only and batched by the OS.


Decision 2: In-Memory Indexes with Bounded Lifetime

Every lookup the system needs is backed by a Go map held in memory, protected by sync.RWMutex:

// auth.go -- multiple in-memory indexes for O(1) lookup
type AuthStore struct {
    mu           sync.RWMutex
    teams        map[string]*TeamRegistration // login_code -> registration
    byUID        map[string]*TeamRegistration // player UID -> registration
    byCaptainUID map[string]*TeamRegistration // captain UID -> registration
    byEmail      map[string]*TeamRegistration // captain email -> registration
    byID         map[string]*TeamRegistration // team ID -> registration
}

With 2,000 teams at roughly 2 KB per registration struct, the entire index is about 10 MB. That is nothing. And because Go maps are hash tables, every lookup – authentication, duplicate UID check, schedule search – is O(1).

The key insight is that all data fits in memory for this use case. There is no need for a database query planner, B-tree indexes, or WAL files. The NDJSON on disk is the source of truth; the maps are a materialized view that is rebuilt in under a second on restart.


Decision 3: Idempotency Cache with TTL Eviction

Duplicate team registrations are a real problem when thousands of people are hitting submit and getting network timeouts. I built a SHA-256 idempotency cache that tracks recent submissions:

// idempotency.go
type IdempotencyCache struct {
    mu      sync.RWMutex
    entries map[string]time.Time // key -> first-seen time
    ttl     time.Duration        // 24h default
}

The critical optimization here is the background eviction goroutine that runs every hour and deletes expired entries:

func (c *IdempotencyCache) evict() {
    c.mu.Lock()
    defer c.mu.Unlock()
    cutoff := time.Now().Add(-c.ttl)
    for k, t := range c.entries {
        if t.Before(cutoff) {
            delete(c.entries, k)
        }
    }
}

Without this, the map would grow unbounded on a long-running server. With a 24-hour TTL and eviction, the cache stays at a constant size proportional to daily traffic, not cumulative traffic. On a 512 MB box, unbounded growth is how you get OOM-killed at 3 AM.


Decision 4: Token Bucket Rate Limiter with Stale IP Eviction

The rate limiter is a classic per-IP token bucket, but the critical detail for low-memory deployments is the stale IP eviction:

// ratelimit.go
type RateLimiter struct {
    mu       sync.Mutex
    buckets  map[string]*bucket  // IP -> token bucket
    rate     float64
    burst    float64
    staleTTL time.Duration       // 10 minutes
}

Every 5 minutes, a background goroutine removes IP entries that have not been seen in the last 10 minutes:

func (rl *RateLimiter) evict() {
    rl.mu.Lock()
    defer rl.mu.Unlock()
    cutoff := time.Now().Add(-rl.staleTTL)
    for ip, b := range rl.buckets {
        if b.lastSeen.Before(cutoff) {
            delete(rl.buckets, ip)
        }
    }
}

Under a bot attack, unique IPs can accumulate quickly. Without eviction, a DDoS with 100K unique IPs would allocate a bucket for each one and never free it. The 10-minute staleTTL ensures the map stabilizes at the number of active clients, not the number of historical clients.


Decision 5: Throttled Email Queue (Not Goroutine-per-Email)

Sending confirmation emails on registration is non-negotiable for the product. The naive approach – go sendEmail(...) on every request – is catastrophic on a constrained box. If 500 teams register in an hour, you have 500 goroutines holding open SMTP connections, each consuming stack memory and file descriptors.

Instead, I built a channel-based throttled queue with configurable burst sending:

// email.go
func (ec *EmailConfig) StartQueue(stop <-chan struct{}, batchSize int) {
    ec.queue = make(chan mailJob, 5000) // buffered channel, bounded
    go func() {
        batch := batchSize  // e.g., 14 emails per burst
        for {
            sent := 0
            for sent < batch {
                select {
                case job := <-ec.queue:
                    go func(j mailJob) {
                        ec.sendHTML(j.To, j.Subject, j.Body)
                    }(job)
                    sent++
                default:
                    sent = batch // nothing to send, break
                }
            }
            // Pause 200ms between bursts
            <-time.After(200 * time.Millisecond)
        }
    }()
}

This gives us:

  • A bounded buffer of 5,000 pending emails (the channel itself is just pointers)
  • At most batchSize concurrent SMTP connections at any moment
  • Back-pressure: if the queue fills, new emails are dropped with a warning rather than OOM-ing the process
  • The batch size is dynamically adjustable from the admin dashboard without requiring a restart

The dashboard exposes email_rate_per_sec as a configurable setting, so the operator can tune it based on the SMTP provider’s rate limits.


Decision 6: Atomic Writes Everywhere

On a 512 MB box, sudden power loss or OOM kills are realistic failure modes. Every file write in the system uses the write-tmp-then-rename pattern:

// stats.go
func (s *Stats) writeToDisk() {
    snap := s.Snapshot()
    data, _ := json.MarshalIndent(snap, "", "  ")

    target := filepath.Join(s.dataDir, "stats.json")
    tmp := target + ".tmp"

    os.WriteFile(tmp, data, 0644)
    os.Rename(tmp, target) // atomic on Linux (same filesystem)
}

This pattern is used for stats.json, settings.json, groups.json, audit.json, and the ReplaceWith operation on the main NDJSON file. It guarantees that a crash mid-write never leaves a corrupted file – the previous version is always intact until the rename succeeds.


Decision 7: systemd Resource Ceilings

Even with all the application-level optimizations, a bug or unexpected load could still cause the process to eat all available memory and take down the OS. The systemd service file enforces hard limits:

# ffbpl-api.service
MemoryMax=256M     # hard OOM-kill ceiling
MemoryHigh=192M    # kernel starts reclaiming above this
CPUQuota=80%       # never starve sshd or the OS

The Go binary itself idles at about 12-18 MB RSS. Under peak load (concurrent registrations + admin dashboard activity + email blasts), it stabilizes around 40-60 MB. The 256 MB ceiling gives a 4x safety margin but still leaves 256 MB for the OS, Apache, and sshd.

Additional hardening:

ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/ffbpl
NoNewPrivileges=yes
PrivateTmp=yes

The process cannot read anything outside its data directory. If the binary is compromised, the blast radius is contained.


The Cloudflare Layer: How Static HTML Caching Handles the Real Traffic

Here is the thing most people miss: the Go backend does not serve the landing page. Cloudflare does.

The public registration site is a single index.html file – 122 KB of self-contained HTML, CSS, and JavaScript. No build step, no bundler, no SSR. It is served as a static file by Apache and cached at Cloudflare’s edge:

# ffbpl.com.conf -- Apache serves static, proxies /api/
DocumentRoot /var/www/html
<Directory /var/www/html>
    Options -Indexes +FollowSymLinks
    AllowOverride None
    Require all granted
</Directory>

# SPA fallback
RewriteEngine on
RewriteCond %{REQUEST_URI} !^/api/
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f
RewriteRule ^ /index.html [L]

# Only API requests hit the Go backend
<Location /api/>
    ProxyPass        http://127.0.0.1:8090/api/
    ProxyPassReverse http://127.0.0.1:8090/api/
</Location>

With Cloudflare’s cache set to cache HTML, the architecture looks like this:

User -> Cloudflare Edge (cache HIT: HTML/CSS/JS) -> Apache on VPS (cache MISS: /api/*) -> Go :8090 (API only)

The result: when BPL registration goes live and 50,000 people load the page simultaneously, zero of those requests hit the VPS. They are all served from Cloudflare’s edge cache. The Go backend only sees actual API calls – form submissions, logins, schedule lookups – which are a tiny fraction of total traffic.

The API responses themselves use Cache-Control headers strategically:

// Stats endpoint -- cached for 30 seconds
w.Header().Set("Cache-Control", "public, max-age=30")

// Form config -- cached for 60 seconds
w.Header().Set("Cache-Control", "public, max-age=60")

This means even the lightweight GET endpoints are served from cache for most visitors. The only requests that must reach the origin are POSTs (registration, login).

Cloudflare Real IP Forwarding

When requests do reach the origin, we need the real client IP for rate limiting and audit logging, not Cloudflare’s edge IP. The Apache config strips and re-injects the correct IP using mod_remoteip:

RemoteIPHeader CF-Connecting-IP
RemoteIPTrustedProxy 173.245.48.0/20
RemoteIPTrustedProxy 103.21.244.0/22
# ... all Cloudflare IPv4 + IPv6 ranges

RequestHeader set X-Real-IP "%{REMOTE_ADDR}s"
RequestHeader add X-Forwarded-For "%{REMOTE_ADDR}s"

The Go middleware then extracts the real IP from the trusted header chain:

func extractIP(r *http.Request) string {
    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
        if idx := strings.IndexByte(xff, ','); idx != -1 {
            return strings.TrimSpace(xff[:idx])
        }
        return strings.TrimSpace(xff)
    }
    if xri := r.Header.Get("X-Real-IP"); xri != "" {
        return strings.TrimSpace(xri)
    }
    ip, _, _ := net.SplitHostPort(r.RemoteAddr)
    return ip
}

This ensures rate limiting works correctly per-client, not per-Cloudflare-edge.


Decision 8: Two Servers, One Binary

The public API (:8090) and the admin dashboard (:8091) run as separate http.Server instances in the same process:

publicSrv := &http.Server{
    Addr:              ":8090",
    ReadTimeout:       30 * time.Second,
    ReadHeaderTimeout: 5 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       60 * time.Second,
    MaxHeaderBytes:    1 << 20,
}

dashSrv := &http.Server{
    Addr:              ":8091",
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      15 * time.Second,
    IdleTimeout:       60 * time.Second,
}

Why not two separate binaries? Memory. A second Go process means a second runtime, a second GC, a second set of stacks. By sharing a single process, both servers share the same heap, the same in-memory indexes, and the same background goroutines (stats, email queue, rate limiter cleanup, idempotency eviction).

The public server has longer timeouts (30s) to accommodate file uploads. The dashboard has shorter timeouts (15s) because admin actions are lightweight JSON RPCs. Both share the same graceful shutdown signal:

ctx, stop := signal.NotifyContext(context.Background(),
    os.Interrupt, syscall.SIGTERM)
defer stop()
done := ctx.Done()

// All background goroutines receive this channel
stats := NewStats(dataDir, logger, 30*time.Second, done)
idem  := NewIdempotencyCache(24*time.Hour, done)
rl    := NewRateLimiter(10, 5, done)

<-done
publicSrv.Shutdown(shutdownCtx)
dashSrv.Shutdown(shutdownCtx)

Every background goroutine – stats snapshots, rate limiter cleanup, idempotency eviction, email queue – listens on the same done channel. When the process receives SIGTERM, everything drains cleanly before exit.


Decision 9: Zero External Dependencies

The go.mod file is three lines:

module ffbpl/api
go 1.22.0

No third-party packages. No ORM, no web framework, no logging library, no UUID generator. The entire module is stdlib-only:

  • net/http for routing and serving
  • crypto/rand + crypto/sha256 for secure random generation and hashing
  • encoding/json for serialization
  • net/smtp for email
  • log/slog for structured logging (added in Go 1.21)
  • syscall for file locking on Linux

Every dependency is a liability on a constrained system. Third-party packages import their own dependencies, which import theirs. A single go get github.com/some/router can pull in 20 transitive packages and add 5 MB to the binary. The compiled FFBPL binary is ~10 MB – smaller than most framework-based hello-world apps.


The Numbers

Here is what the system looks like in production on a 512 MB / 1 vCPU VPS:

Metric Value
Binary size ~10 MB
Idle RSS 12-18 MB
Peak RSS (under load) 40-60 MB
Startup time (cold boot) < 1 second
NDJSON rebuild (2,000 teams) ~200 ms
Request latency (p50, API) < 5 ms
Request latency (p99, API) < 25 ms
Concurrent registrations handled 50+/sec
Total requests served (via CF) 1,000,000+
Total requests hitting origin ~15,000 (1.5% of total)
Uptime 30+ days without restart
Background goroutines 5 (stats, idem, ratelimit, email, signal)

The 98.5% cache hit ratio from Cloudflare is the real hero. The Go backend is optimized to be fast, but it barely needs to be – it only handles the writes and the authenticated reads.


Lessons Learned

  1. Flat files are not a dirty word. For append-mostly workloads with datasets that fit in memory, NDJSON + in-memory indexes beats a database in simplicity, performance, and resource usage. The trade-off is no ad-hoc queries, but for a purpose-built system, you know your access patterns upfront.

  2. Bounded data structures are non-negotiable. Every map, every channel, every buffer has to have a maximum size or a TTL-based eviction strategy. This is the difference between a system that runs for months and one that OOM-kills in a week.

  3. Let the CDN do the heavy lifting. If 95%+ of your traffic is serving the same static HTML, the backend is not your bottleneck – the CDN layer is your actual web server. Invest in correct Cache-Control headers and Cloudflare page rules.

  4. One process is better than many. On a 512 MB box, every additional process costs OS overhead, context switching, and memory fragmentation. Two HTTP servers in one Go binary cost almost nothing extra compared to running them separately.

  5. systemd is your safety net. Hard memory ceilings (MemoryMax), automatic restart on failure, and security hardening (ProtectSystem=strict) mean you can sleep at night even on a cheap VPS.

  6. Zero dependencies is a feature. Fewer imports mean smaller binaries, faster builds, fewer CVEs to track, and most importantly, no surprises from transitive dependency updates.


The entire system – public API, admin dashboard, team registration, group management, tournament brackets, email blasts, audit logging – runs comfortably on the cheapest VPS tier available. Sometimes the best optimization is not making things faster; it is making things smaller.


Built and maintained by Rhythm113. The project is FFBPL - the backend powering Bangladesh Pro League 2026.