Setup #

The most expensive decisions in a web application aren’t the ones made when the system is already large and complex — they’re the ones ignored in the first days of development. Security headers not installed from the start, database connection pools not configured, graceful shutdown not implemented — all of these are technical debt that stays invisible until the system starts failing in production. This article covers the foundations that need to exist before the first business code line is written: from the HTTP layer to deployment safety, from security to observability. Not framework or architecture choices — but setup principles that apply to every stack.

Why Initial Setup Matters #

The cost of fixing structural problems in a running application is far higher than doing them right from the start. Some concrete examples:

Problems that are expensive to fix later:

1. Missing security headers
   Early: add 10 lines of config → done
   Later: audit all responses, regression tests, coordinate with the CDN → weeks

2. Database connection pool not configured
   Early: 3 lines of config during setup
   Later: diagnose connection leaks in production, incident downtime, trace root causes → days

3. No graceful shutdown
   Early: implement in main() the first time
   Later: in-flight requests cut during deploys, data corruption, user complaints → emergency patches

4. CORS wildcard (*)
   Early: whitelist domains in the first config
   Later: cross-origin credential abuse already happened, damage audits, auth revamps → incident

The principle: every item in this article takes 30 minutes–2 hours if done during setup,
and can take days if done after production has experienced problems.

The HTTP and Network Layer #

Response Compression #

Compression reduces the size of responses sent to clients. For text-based responses (HTML, JSON, CSS, JavaScript), compression can reduce size by 60–80%.

What should be compressed:
  ✓ HTML, CSS, JavaScript
  ✓ JSON responses from APIs
  ✓ SVG and XML
  ✓ Plain text

What must not be compressed:
  ✗ Images (JPEG, PNG, WebP are already compressed — compressing again just wastes CPU)
  ✗ Video and audio
  ✗ ZIP files, PDFs, and other already-compressed binaries

Algorithm choices:
  Gzip:   Supported by all browsers for a long time. The safe default.
  Brotli: Better compression (~20% smaller than Gzip), supported by modern browsers.
          Requires HTTPS. Use Brotli if the app is fully HTTPS.

How to check whether compression is active:
  curl -H "Accept-Encoding: gzip" -I https://example.com/api/data
  → Should show: Content-Encoding: gzip in the response headers

Example Nginx configuration:
  gzip on;
  gzip_types text/plain application/json text/css application/javascript;
  gzip_min_length 1000;  # don't compress very small responses

HTTP Security Headers #

Many web attacks — clickjacking, MIME sniffing, XSS, downgrade attacks — can be prevented just by setting the right headers. This is the cheapest and most often ignored security layer.

Mandatory headers for every web application:

Strict-Transport-Security: max-age=31536000; includeSubDomains
  → Force HTTPS. After this, browsers will never send requests over HTTP.
  → Add preload if ready to register on the HSTS preload list.

X-Content-Type-Options: nosniff
  → Prevent browsers from "guessing" content types.
  → If the server says this is application/json, the browser won't try other interpretations.

X-Frame-Options: DENY
  → Prevent this page from being loaded inside an iframe.
  → Protects against clickjacking attacks.
  → Use SAMEORIGIN if same-domain iframes are needed.

Referrer-Policy: strict-origin-when-cross-origin
  → Limit the Referer information sent to other domains.
  → Prevent sensitive URLs (possibly containing tokens) from leaking to third parties.

Content-Security-Policy: default-src 'self'
  → Restrict which resource sources this page may load.
  → Start strict, relax gradually based on needs.
  → Prevents script injection from external sources.

Permissions-Policy: camera=(), microphone=(), geolocation=()
  → Disable browser features the app doesn't need.
  → Prevent malicious scripts from exploiting unnecessary permissions.
// Implementation in middleware (Go example):
func securityHeadersMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
        w.Header().Set("X-Content-Type-Options", "nosniff")
        w.Header().Set("X-Frame-Options", "DENY")
        w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
        w.Header().Set("Content-Security-Policy", "default-src 'self'")
        w.Header().Set("Permissions-Policy", "camera=(), microphone=()")
        next.ServeHTTP(w, r)
    })
}
Use tools like securityheaders.com to audit the headers already installed. The tool gives a grade from A to F along with specific recommendations. The minimum target is grade B before go-live, grade A for applications handling sensitive data.

The Security Layer #

CSRF Protection #

CSRF (Cross-Site Request Forgery) is an attack where a malicious site forces a user’s browser to send requests to your app without the user’s knowledge. Because browsers automatically include cookies, those requests look legitimate server-side.

sequenceDiagram
    participant U as User
    participant B as Browser
    participant M as Malicious Site
    participant A as Your App

    Note over U,A: Without CSRF protection

    U->>A: Logs into the app → session cookie stored
    U->>M: Visits the malicious site (email link, etc.)
    M->>B: Page contains: <img src="https://yourapp.com/transfer?to=attacker&amount=1000000">
    B->>A: GET /transfer?to=attacker&amount=1000000 [with the session cookie automatically!]
    A->>A: Cookie valid → transfer executed
    Note over A: Money transferred without the user noticing

    Note over U,A: With CSRF protection
    A-->>B: Every form contains a unique CSRF token
    M->>B: The malicious page has no valid CSRF token
    B->>A: Request without a CSRF token → rejected 403
When CSRF protection is mandatory:
  ✓ Web apps using session-based authentication (cookies)
  ✓ State-changing endpoints (POST, PUT, PATCH, DELETE)

When CSRF protection isn't needed:
  ✗ Stateless APIs using Bearer Tokens (not cookies)
     → Tokens aren't automatically sent by browsers, so CSRF attacks don't apply
  ✗ GET-only endpoints (read-only, no side effects)

CSRF token implementation:
  1. Generate a random token per session (or per request for more security)
  2. Store the token in the session
  3. Include the token in every form as a hidden field
  4. Include the token in AJAX requests as a header (X-CSRF-Token)
  5. Validate the token on every non-GET request

Cookie configuration for CSRF defense:
  SameSite=Lax   → Cookies not sent on cross-site requests from external links
  SameSite=Strict → Cookies not sent on any cross-site request
  HttpOnly        → Cookies inaccessible to JavaScript
  Secure          → Cookies only sent over HTTPS

Authentication and Session Management #

Errors in session management are one of the most common attack entry points.

Session-based authentication:
  ✓ Regenerate the session ID after login (session fixation prevention)
  ✓ Set an idle timeout (inactive sessions invalidated automatically)
  ✓ Store sessions in Redis or a database, NOT local memory
     (local memory doesn't survive restarts, doesn't work with multiple instances)
  ✓ Set session cookies: HttpOnly + Secure + SameSite
  ✓ Logout must invalidate the session server-side, not just delete the cookie

JWT-based authentication:
  ✓ Access tokens short-lived (5-15 minutes)
  ✓ Refresh tokens stored in HttpOnly Secure cookies
  ✓ NEVER store JWTs in localStorage (vulnerable to XSS)
  ✓ Validate signature, exp, iss, aud on every request

// ANTI-PATTERN: Sessions in local memory
var sessions = map[string]Session{}  // lost on restart, not scalable

// CORRECT: Sessions in Redis
func (s *SessionStore) Get(id string) (*Session, error) {
    data, err := s.redis.Get(ctx, "session:"+id).Bytes()
    // ...
}

Rate Limiting #

Rate limiting isn’t just for performance — it’s a defense against brute force, credential stuffing, and abuse.

Endpoints that must have stricter rate limiting:
  POST /auth/login          → 5 requests/minute per IP
  POST /auth/register       → 3 requests/minute per IP
  POST /auth/forgot-password → 3 requests/minute per email
  POST /auth/verify-otp     → 3 requests/minute per user

Endpoints needing standard rate limiting:
  All API endpoints → 100-1000 requests/minute per user/IP

The correct response when rate limited:
  HTTP 429 Too Many Requests
  Retry-After: 47  (how many seconds until the next attempt)
  X-RateLimit-Limit: 5
  X-RateLimit-Remaining: 0
  X-RateLimit-Reset: 1706356047

Assets and Static Content #

Asset Versioning and Caching #

Without asset versioning, users may keep using old CSS or JavaScript after deployments because of browser caching.

// ANTI-PATTERN: Assets without versioning
<link href="/styles.css" rel="stylesheet">
<script src="/app.js"></script>
→ After deployment, browsers still use the old cached version

// CORRECT: Assets with content hashes
<link href="/styles.4f3a2c1b.css" rel="stylesheet">
<script src="/app.8d2e5f9a.js"></script>
→ Hashes change when content changes → cache invalidated automatically
→ Same hash if content unchanged → cache stays valid

Cache-Control for hashed assets:
  Cache-Control: public, max-age=31536000, immutable
  → Cache for 1 year, immutable (browsers don't need to revalidate)
  → Safe because the URL definitely changes when content changes

Cache-Control for HTML (which references the assets):
  Cache-Control: no-cache
  → Browsers revalidate each time, but can use the cache if the ETag matches
  → Ensures the latest HTML is always loaded so it references the newest asset hashes

CORS Configuration #

Overly permissive CORS is a security issue that’s often underestimated.

// ANTI-PATTERN: Wildcard with credentials (a dangerous combination)
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
// This is invalid per the spec — browsers will reject it
// But some server implementations are inconsistent

// ANTI-PATTERN: Wildcard without credentials (still too permissive)
Access-Control-Allow-Origin: *
// Allows requests from any domain — including malicious sites

// CORRECT: Explicit origin whitelist
var allowedOrigins = []string{
    "https://app.example.com",
    "https://admin.example.com",
}

func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        origin := r.Header.Get("Origin")
        for _, allowed := range allowedOrigins {
            if origin == allowed {
                w.Header().Set("Access-Control-Allow-Origin", origin)
                w.Header().Set("Vary", "Origin")  // important for caching
                break
            }
        }
        // ...
    })
}

// Limit the allowed methods and headers:
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID
Access-Control-Max-Age: 3600  // cache the preflight response for 1 hour

The Performance Baseline #

Database Connection Pools #

This is the most often ignored configuration and the most common cause of production incidents.

Problems without a connection pool:
  Every request opens a new database connection
  → Connections are slow (~5-50ms to establish)
  → Databases have connection limits (e.g. PostgreSQL default 100)
  → Load spikes = connections exhausted = the entire system unresponsive

Problems with a misconfigured pool:
  Connection leaks: connections never returned to the pool
  → Pool full → requests wait → timeouts → mass errors

Configuration that needs setting:
  MaxOpenConns     = the maximum number of active database connections
  MaxIdleConns     = the number of idle connections kept in the pool
  ConnMaxLifetime  = a connection's maximum lifetime (prevents stale connections)
  ConnMaxIdleTime  = how long an idle connection stays open before closing

Initial value guidance (adjust based on load):
  MaxOpenConns:    25 per instance (4 instances → 100 database connections)
  MaxIdleConns:    10
  ConnMaxLifetime: 5 minutes
  ConnMaxIdleTime: 1 minute

// Go example:
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(1 * time.Minute)

Layered Caching Strategy #

flowchart LR
    Client["Browser / Mobile"]
    CDN["CDN Cache\n(Edge)"]
    AppCache["Application Cache\n(Redis)"]
    DB["Database"]

    Client -->|"Request"| CDN
    CDN -->|"Cache miss"| AppCache
    AppCache -->|"Cache miss"| DB
    DB -->|"Data"| AppCache
    AppCache -->|"Cache hit"| CDN
    CDN -->|"Cache hit"| Client

    style CDN fill:#27AE60,color:#fff
    style AppCache fill:#E67E22,color:#fff
    style DB fill:#8E44AD,color:#fff
Caching layers and what fits each:

Browser Cache (Cache-Control header):
  → Static assets with content hashes: max-age=31536000, immutable
  → HTML: no-cache (revalidate every time)
  → Rarely changing API responses: max-age=300

CDN Cache:
  → All static assets (JavaScript, CSS, images)
  → Non-user-specific API responses
  → Configuration: most aggressive for assets, more conservative for API responses

Application Cache (Redis):
  → Config and feature flags
  → Session data
  → Expensive, read-heavy query results
  → Rate limit counters
  TTLs should match how often the data changes

Error Handling and Observability #

Consistent Error Responses #

// ANTI-PATTERN: Errors leaking internal details
HTTP 500
{
  "error": "pq: duplicate key value violates unique constraint \"users_email_key\"",
  "sql": "INSERT INTO users (email, ...) VALUES ($1, ...)"
}
// Attackers learn: the database is PostgreSQL, the table name, the constraint name

// CORRECT: Informative but safe errors
HTTP 409 Conflict
{
  "error": {
    "code": "EMAIL_ALREADY_REGISTERED",
    "message": "This email is already registered. Use the forgot password feature if you've forgotten your password."
  }
}
// Users get actionable information, without internal details

Structured Logging #

// ANTI-PATTERN: Unstructured logs
log.Println("Request processed: user 123, endpoint /orders, 234ms, status 200")
// Hard to parse, hard to query, hard to aggregate

// CORRECT: Structured JSON logging
{
  "timestamp": "2024-01-27T14:32:01.123Z",
  "level": "INFO",
  "request_id": "req_abc123def456",
  "user_id": "usr_789",
  "method": "GET",
  "path": "/api/orders",
  "status": 200,
  "duration_ms": 234,
  "ip": "203.0.113.1"
}
// Queryable, filterable, aggregatable, and automatically alertable

Required log fields for every request log: timestamp, request_id, method, path, status, duration_ms. Required fields for authenticated requests: add user_id. Never log: passwords, tokens, credit cards, or unnecessary PII.

Health Checks and Readiness #

Two endpoints with different purposes:

GET /health (liveness check):
  → "Is this process still alive?"
  → If it doesn't respond → the container/process gets restarted
  → Must be very fast, must not check dependencies (DB, Redis)
  → Return 200 OK if the process is running, regardless of dependency conditions

GET /ready (readiness check):
  → "Is this service ready to receive traffic?"
  → If it doesn't respond → traffic is routed to another instance (not killed)
  → May check: can the DB be queried? Can Redis be pinged?
  → Return 200 if all dependencies are ready, 503 if not

// Example of an informative readiness response:
HTTP 200 OK
{
  "status": "ready",
  "checks": {
    "database": "ok",
    "redis": "ok",
    "disk_space": "ok"
  },
  "version": "v1.42.0"
}

// Example response when not ready:
HTTP 503 Service Unavailable
{
  "status": "not_ready",
  "checks": {
    "database": "ok",
    "redis": "connection_refused",  ← this is the problem
    "disk_space": "ok"
  }
}

Configuration and Environment #

Secrets Management #

A good configuration hierarchy:

Hardcoded in code:          NEVER for credentials and secrets
.env files in repos:        NEVER (except templates without real values)
Environment variables:       For deployments, containers, CI/CD
Config files (non-secret):   For non-sensitive values needing versioning
Secrets managers:            For production (AWS Secrets Manager, Vault, etc.)

// ANTI-PATTERN: Hardcoded credentials
const (
    DBPassword = "supersecret123"
    JWTSecret  = "myjwtsecret"
    APIKey     = "«redacted:sk_live_…»"
)

// CORRECT: From environment variables
dbPassword := os.Getenv("DB_PASSWORD")
if dbPassword == "" {
    log.Fatal("DB_PASSWORD environment variable is required")
}

// Fail fast when required config is missing — don't let the app
// start with empty values that may cause further problems

Environment Separation #

The three environments that should exist:

Development (local):
  → Local database, no real user data
  → Verbose error details are fine
  → No HTTPS needed
  → Mock third-party services if needed

Staging:
  → Configuration as close to production as possible
  → Synthetic or anonymized data
  → HTTPS
  → Connections to third-party staging/sandbox (not production)
  → The place to test before deploying to production

Production:
  → All config loaded from secrets managers or environment variables
  → Minimal error responses (no technical details)
  → All security features enabled
  → Monitoring and alerting active

The main dangers without environment separation:
  → Developers testing directly on the production database
  → Production credentials leaking into developer environments
  → Testing that accidentally deletes or corrupts production data

Deployment and Runtime Safety #

Graceful Shutdown #

Graceful shutdown ensures that during deployments or restarts, in-flight requests finish before the process closes.

// Graceful shutdown implementation in Go
func main() {
    server := &http.Server{
        Addr:    ":8080",
        Handler: setupRouter(),
    }

    // Channel to capture signals from the OS
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

    // Run the server in a separate goroutine
    go func() {
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("Server error: %v", err)
        }
    }()

    log.Println("Server started on :8080")

    // Block until a signal is received
    <-quit
    log.Println("Shutting down server...")

    // Give active requests 30 seconds to finish
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }

    log.Println("Server exited properly")
}

Timeouts at Every Level #

Timeouts that need configuring:

HTTP server:
  ReadTimeout:       5s   (time limit for reading requests from clients)
  WriteTimeout:      15s  (time limit for writing responses to clients)
  IdleTimeout:       60s  (how long idle keep-alive connections stay open)
  ReadHeaderTimeout: 2s   (time limit for reading headers only)

Database queries:
  Per query: 5s (queries over 5 seconds → something's wrong)
  Configure via context: ctx, cancel := context.WithTimeout(ctx, 5*time.Second)

External API calls:
  HTTP client timeout: match the third-party SLA
  Minimum: 3-10 seconds, maximum as needed

Why timeouts matter:
  → Without timeouts, one slow request can block a goroutine/thread forever
  → Accumulating waiting goroutines → memory exhaustion → OOM
  → The system collapses because of one slow dependency

// Example server configuration with all timeouts:
server := &http.Server{
    Addr:              ":8080",
    Handler:           handler,
    ReadTimeout:       5 * time.Second,
    WriteTimeout:      15 * time.Second,
    IdleTimeout:       60 * time.Second,
    ReadHeaderTimeout: 2 * time.Second,
}
Unconfigured timeouts are time bombs. One database query without a timeout can make a goroutine wait forever, accumulate until memory is exhausted, and bring down the entire service — because of a single problematic query. Configure timeouts at every level: server, database, and external API calls.

Initial Setup Checklist #

HTTP AND NETWORK:
  □ Response compression active (gzip or brotli)
  □ HTTP security headers complete (HSTS, X-Content-Type-Options, X-Frame-Options, etc.)
  □ HTTPS in all environments (development may be the exception)

SECURITY:
  □ CSRF protection active (if session-based auth)
  □ Sessions: HttpOnly + Secure + SameSite cookies
  □ Sessions stored in Redis/DB, not local memory
  □ Rate limiting on authentication endpoints
  □ CORS explicit whitelist, not wildcard

ASSETS AND STATIC:
  □ Asset versioning with content hashes
  □ Static assets on a CDN or object storage
  □ Cache-Control headers appropriate for each content type

PERFORMANCE:
  □ Database connection pool configured (max open, max idle, lifetime)
  □ Caching strategy defined per data type

ERRORS AND OBSERVABILITY:
  □ Generic error responses to clients (no internal details)
  □ Structured JSON logging with request_id
  □ /health endpoint (liveness check, no dependency checks)
  □ /ready endpoint (readiness check, dependency checks)
  □ Metrics: request counts, latency histograms, error rates

CONFIGURATION:
  □ Credentials and secrets in environment variables, not hardcoded
  □ The app fails fast when required config is missing
  □ Separate environments: development, staging, production

DEPLOYMENT:
  □ Graceful shutdown implemented
  □ Timeouts configured: server, DB, external APIs
  □ Resource limits configured (memory, CPU for containers)

Summary #

  • Initial setup is an investment, not overhead — every checklist item takes 30 minutes during setup and can take days to fix after production has problems.
  • HTTP security headers are a free security layer — one middleware with 6-8 lines of config prevents clickjacking, MIME sniffing, XSS from CDN injection, and downgrade attacks.
  • CSRF is mandatory for session-based auth, unnecessary for Bearer Tokens — understand the difference so you neither misimplement nor skip required protections.
  • Database connection pools are the foundation of reliability — without proper configuration, even a small load spike can exhaust database connections and make the whole system unresponsive.
  • Timeouts at every level defend against cascading failures — one slow dependency without a timeout can pile up goroutines/threads and crash the entire service.
  • Graceful shutdown is mandatory for zero-downtime deployments — without it, every deployment risks cutting in-flight requests and causing user-facing errors.
  • Structured logging is the foundation of debuggability — unstructured logs can’t be queried, can’t be alerted on, and become useless text piles during incidents.
  • Asset versioning with content hashes solves cache invalidation — hashes change when content changes, enabling aggressive caching without the risk of users getting stale assets.
  • CORS whitelists, not wildcards — wildcards combined with cookies or credentials are a security issue often ignored until abuse happens.
  • Secrets must never live in code or repos — use environment variables or secrets managers. Apps must fail fast when required config is missing, not run with empty values.
#

← Previous: API Security   Next: Validation

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact