Skip to content
·27 min read

Full-Stack VPS Deployment: React, Node, Go, Postgres, Redis, ClickHouse, Redpanda & More

A complete guide to deploying a production-ready full-stack application on a fresh VPS — covering server hardening, message streaming, object storage, search, email, task queues, and observability.

This guide takes a fresh Ubuntu 24.04 VPS from zero to a production-ready deployment running:

  • React SPA served via Nginx
  • Node.js API (via PM2)
  • Go binary service (via systemd)
  • PostgreSQL 16 with pgvector + pg_cron extensions
  • Redis 7 with persistence
  • ClickHouse for analytics
  • Redpanda (Kafka-compatible event streaming)
  • NATS (lightweight pub/sub + job queue)
  • Garage (S3-compatible object storage, MIT licensed)
  • Typesense (full-text search)
  • Nginx reverse proxy with TLS (Certbot)
  • UFW firewall + Fail2ban
  • Centralized logging (journald + Vector + ClickHouse)
  • Monitoring (Prometheus + Grafana, optional)
  • OpenTelemetry traces + metrics

Estimated time: 2–4 hours on a clean machine depending on which services you need.


1. Initial Server Setup

1.1 First login as root

ssh root@YOUR_SERVER_IP

Update everything before touching anything else:

apt update && apt upgrade -y
apt install -y curl wget git build-essential unzip ufw fail2ban htop

1.2 Create a non-root user

useradd -m -s /bin/bash deploy
usermod -aG sudo deploy
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys

From this point, all commands run as deploy unless noted.

su - deploy

1.3 Harden SSH

sudo nano /etc/ssh/sshd_config

Set these values:

Port 2222                    # non-default port
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
X11Forwarding no
AllowUsers deploy
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
sudo systemctl restart sshd

Warning: Open a second terminal and confirm you can still log in on port 2222 before closing your current session.


2. Firewall (UFW)

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 2222/tcp      # SSH on new port
sudo ufw allow 80/tcp        # HTTP (Certbot challenge)
sudo ufw allow 443/tcp       # HTTPS
sudo ufw enable
sudo ufw status verbose

Services like Postgres, Redis, and ClickHouse should never be exposed publicly. They bind to 127.0.0.1 only. If you need remote access, use an SSH tunnel:

ssh -L 5432:localhost:5432 deploy@YOUR_SERVER_IP -p 2222

3. Fail2ban

sudo nano /etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5

[sshd]
enabled  = true
port     = 2222
logpath  = /var/log/auth.log
maxretry = 3
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd

4. PostgreSQL 16

4.1 Install

sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-16 postgresql-contrib-16

4.2 Create database and user

sudo -u postgres psql
CREATE USER appuser WITH PASSWORD 'strong_random_password';
CREATE DATABASE appdb OWNER appuser;
\c appdb
-- allow only what is needed
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO appuser;
GRANT CREATE ON SCHEMA public TO appuser;
\q

4.3 Tune postgresql.conf

sudo nano /etc/postgresql/16/main/postgresql.conf

Sensible defaults for a 4 GB RAM VPS:

max_connections = 100
shared_buffers = 1GB
effective_cache_size = 3GB
maintenance_work_mem = 256MB
checkpoint_completion_target = 0.9
wal_buffers = 16MB
default_statistics_target = 100
random_page_cost = 1.1
effective_io_concurrency = 200
work_mem = 10485kB
min_wal_size = 1GB
max_wal_size = 4GB

Scale shared_buffers to 25% of RAM and effective_cache_size to 75%.

4.4 Restrict pg_hba

sudo nano /etc/postgresql/16/main/pg_hba.conf

Remove or comment all lines that allow remote connections. Keep only:

local   all             postgres                                peer
local   all             all                                     peer
host    all             all             127.0.0.1/32            scram-sha-256
host    all             all             ::1/128                 scram-sha-256
sudo systemctl restart postgresql

4.5 Extensions

pgvector (embeddings / similarity search):

sudo apt install -y postgresql-16-pgvector
sudo -u postgres psql -d appdb -c "CREATE EXTENSION IF NOT EXISTS vector;"

pg_cron (scheduled jobs inside Postgres):

sudo apt install -y postgresql-16-pg-cron

Add to postgresql.conf:

shared_preload_libraries = 'pg_cron'
cron.database_name = 'appdb'
sudo systemctl restart postgresql
sudo -u postgres psql -d appdb -c "CREATE EXTENSION IF NOT EXISTS pg_cron;"

Schedule a vacuum example:

SELECT cron.schedule('nightly-vacuum', '0 3 * * *', 'VACUUM ANALYZE');

PostGIS (geospatial):

sudo apt install -y postgresql-16-postgis-3
sudo -u postgres psql -d appdb -c "CREATE EXTENSION IF NOT EXISTS postgis;"

Automatic backups with pg_dump:

sudo mkdir -p /var/backups/postgres
sudo chown deploy:deploy /var/backups/postgres

Create /home/deploy/scripts/pg_backup.sh:

#!/usr/bin/env bash
set -euo pipefail
DATE=$(date +%Y%m%d_%H%M%S)
PGPASSWORD="strong_random_password" pg_dump \
  -h 127.0.0.1 -U appuser appdb \
  | gzip > /var/backups/postgres/appdb_${DATE}.sql.gz
# Keep last 7 days
find /var/backups/postgres -name "*.sql.gz" -mtime +7 -delete
chmod +x /home/deploy/scripts/pg_backup.sh
crontab -e
# Add:
# 0 2 * * * /home/deploy/scripts/pg_backup.sh

5. Redis 7

5.1 Install

sudo apt install -y redis-server

5.2 Configure

sudo nano /etc/redis/redis.conf

Key settings:

bind 127.0.0.1 -::1
protected-mode yes
requirepass your_redis_password

# Persistence — choose one or both
appendonly yes
appendfsync everysec

# Or RDB only (faster, slightly less durable)
save 900 1
save 300 10
save 60 10000

# Memory limit (adjust per server)
maxmemory 512mb
maxmemory-policy allkeys-lru

# Disable dangerous commands
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command CONFIG ""
rename-command DEBUG ""
sudo systemctl enable --now redis-server
redis-cli -a your_redis_password ping

5.3 Redis ACL (Redis 6+)

For multi-tenant or multi-service setups, create per-service ACLs:

redis-cli -a your_redis_password
ACL SETUSER appuser on >app_password ~app:* &* +@read +@write +@string +@hash +@list +@set
ACL SAVE

6. ClickHouse

6.1 Install

sudo apt install -y apt-transport-https ca-certificates
curl -fsSL 'https://packages.clickhouse.com/rpm/lts/repodata/repomd.xml.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/clickhouse-keyring.gpg

echo "deb [signed-by=/usr/share/keyrings/clickhouse-keyring.gpg] \
  https://packages.clickhouse.com/deb stable main" \
  | sudo tee /etc/apt/sources.list.d/clickhouse.list

sudo apt update
sudo apt install -y clickhouse-server clickhouse-client
sudo systemctl enable --now clickhouse-server

6.2 Secure ClickHouse

sudo nano /etc/clickhouse-server/users.xml

Change the default user password and disable passwordless access:

<users>
  <default>
    <password_sha256_hex>YOUR_SHA256_HASH</password_sha256_hex>
    <networks>
      <ip>::1</ip>
      <ip>127.0.0.1</ip>
    </networks>
    <profile>default</profile>
    <quota>default</quota>
  </default>
</users>

Generate the hash:

echo -n "your_clickhouse_password" | sha256sum | tr -d ' -'

Edit /etc/clickhouse-server/config.xml to bind only localhost:

<listen_host>127.0.0.1</listen_host>
sudo systemctl restart clickhouse-server
clickhouse-client --password your_clickhouse_password

6.3 Create analytics schema

CREATE DATABASE analytics;

CREATE TABLE analytics.events (
    event_id   UUID DEFAULT generateUUIDv4(),
    user_id    UInt64,
    event_name LowCardinality(String),
    properties String,          -- JSON blob
    created_at DateTime DEFAULT now()
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (event_name, created_at, user_id)
TTL created_at + INTERVAL 1 YEAR;

6.4 Useful ClickHouse extensions / table engines

EngineUse case
MergeTreeDefault OLAP; time-series, events
ReplacingMergeTreeUpsert-like deduplication
SummingMergeTreePre-aggregated counters
AggregatingMergeTreeMaterialized aggregations
KafkaStream ingest directly from Kafka
PostgreSQLRead Postgres tables from CH queries

Enable the Postgres engine (to join CH with PG data):

CREATE TABLE pg_users ENGINE = PostgreSQL(
  '127.0.0.1:5432', 'appdb', 'users', 'appuser', 'strong_random_password'
);

7. Node.js API

7.1 Install Node.js via nvm

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 22
nvm use 22
nvm alias default 22

7.2 Install PM2

npm install -g pm2

7.3 Deploy app

mkdir -p /home/deploy/apps/api
cd /home/deploy/apps/api
# Copy your built app or git clone here

Create ecosystem.config.js:

module.exports = {
  apps: [{
    name: 'api',
    script: './dist/server.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production',
      PORT: 3001,
      DATABASE_URL: 'postgresql://appuser:strong_random_password@127.0.0.1:5432/appdb',
      REDIS_URL: 'redis://:your_redis_password@127.0.0.1:6379',
    },
    error_file: '/var/log/deploy/api-error.log',
    out_file: '/var/log/deploy/api-out.log',
    log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
    max_memory_restart: '512M',
  }]
}
sudo mkdir -p /var/log/deploy
sudo chown deploy:deploy /var/log/deploy

pm2 start ecosystem.config.js
pm2 save
pm2 startup  # follow the printed command to enable on reboot

7.4 Environment secrets

Never put secrets in ecosystem.config.js in source control. Use a .env file:

nano /home/deploy/apps/api/.env
chmod 600 /home/deploy/apps/api/.env

Load it in the app with dotenv or set env_file in PM2. Alternatively, use systemd EnvironmentFile= (see Go section below).


8. Go Binary Service

8.1 Install Go

GO_VERSION=1.23.4
curl -LO https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
go version

8.2 Build and deploy

# On your dev machine
GOOS=linux GOARCH=amd64 go build -o bin/service ./cmd/service
scp -P 2222 bin/service deploy@YOUR_SERVER_IP:/home/deploy/apps/go/service

8.3 systemd unit

sudo nano /etc/systemd/system/go-service.service
[Unit]
Description=Go API Service
After=network.target postgresql.service redis.service

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/home/deploy/apps/go
ExecStart=/home/deploy/apps/go/service
Restart=always
RestartSec=5

EnvironmentFile=/home/deploy/apps/go/.env

# Security hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/log/deploy

# Resource limits
LimitNOFILE=65536
MemoryMax=512M

StandardOutput=journal
StandardError=journal
SyslogIdentifier=go-service

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now go-service
sudo systemctl status go-service
journalctl -u go-service -f

9. React SPA

9.1 Build

# On dev machine
npm run build          # outputs to dist/
scp -P 2222 -r dist/ deploy@YOUR_SERVER_IP:/home/deploy/apps/web/

Or build on server:

cd /home/deploy/apps/web
npm ci
npm run build

9.2 Nginx static serving

sudo mkdir -p /var/www/app
sudo cp -r /home/deploy/apps/web/dist/* /var/www/app/
sudo chown -R www-data:www-data /var/www/app

10. Nginx + TLS

10.1 Install

sudo apt install -y nginx
sudo systemctl enable nginx

10.2 Site config

sudo nano /etc/nginx/sites-available/app
# Redirect HTTP → HTTPS
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    # TLS — filled by Certbot
    ssl_certificate     /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    include             /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options    "nosniff"       always;
    add_header X-Frame-Options           "SAMEORIGIN"    always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy        "geolocation=(), microphone=()" always;
    add_header Content-Security-Policy   "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;

    # Logs
    access_log /var/log/nginx/app_access.log combined;
    error_log  /var/log/nginx/app_error.log warn;

    # React SPA — serve index.html for all routes
    location / {
        root  /var/www/app;
        index index.html;
        try_files $uri $uri/ /index.html;

        # Cache static assets
        location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2|ico)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }
    }

    # Node.js API proxy
    location /api/ {
        proxy_pass         http://127.0.0.1:3001/;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade    $http_upgrade;
        proxy_set_header   Connection 'upgrade';
        proxy_set_header   Host       $host;
        proxy_set_header   X-Real-IP  $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 60s;
    }

    # Go service proxy
    location /internal/ {
        proxy_pass         http://127.0.0.1:3002/;
        proxy_http_version 1.1;
        proxy_set_header   Host             $host;
        proxy_set_header   X-Real-IP        $remote_addr;
        proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
    }

    # Rate limiting
    limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
    limit_req zone=api burst=50 nodelay;
}
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

10.3 TLS with Certbot

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Auto-renewal is handled by a systemd timer — confirm:

sudo systemctl status certbot.timer
sudo certbot renew --dry-run

11. Security Hardening

11.1 System-level

# Disable unused services
sudo systemctl disable avahi-daemon cups bluetooth 2>/dev/null || true

# Kernel hardening via sysctl
sudo nano /etc/sysctl.d/99-security.conf
# IP spoofing protection
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# Ignore ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0

# No source routing
net.ipv4.conf.all.accept_source_route = 0

# SYN flood protection
net.ipv4.tcp_syncookies = 1

# Disable IPv6 if unused
net.ipv6.conf.all.disable_ipv6 = 1

# Restrict core dumps
fs.suid_dumpable = 0

# Restrict dmesg to root
kernel.dmesg_restrict = 1
sudo sysctl -p /etc/sysctl.d/99-security.conf

11.2 Automatic security updates

sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades
sudo nano /etc/apt/apt.conf.d/50unattended-upgrades

Enable:

Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Mail "your@email.com";

11.3 AppArmor

Ubuntu ships with AppArmor enabled. Confirm:

sudo aa-status

Nginx and other services have profiles. Enforce them:

sudo aa-enforce /etc/apparmor.d/usr.sbin.nginx

11.4 Secrets management

For small teams, store secrets in /etc/secrets/ with tight permissions:

sudo mkdir -p /etc/secrets
sudo chmod 700 /etc/secrets
sudo nano /etc/secrets/app.env
sudo chmod 600 /etc/secrets/app.env

Reference in systemd units via EnvironmentFile=/etc/secrets/app.env.

For larger teams, consider HashiCorp Vault or Infisical (self-hosted).

11.5 File permissions audit

# World-writable files (should be empty or minimal)
find / -xdev -perm -0002 -type f 2>/dev/null

# SUID/SGID binaries
find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null

12. Logging

12.1 journald configuration

sudo nano /etc/systemd/journald.conf
[Journal]
Storage=persistent
Compress=yes
SystemMaxUse=2G
SystemKeepFree=500M
MaxRetentionSec=1month
ForwardToSyslog=no
sudo systemctl restart systemd-journald

12.2 Nginx log rotation

Nginx logs rotate automatically via /etc/logrotate.d/nginx. Verify:

cat /etc/logrotate.d/nginx

Add structured JSON logging for easier parsing:

log_format json_combined escape=json
  '{'
  '"time":"$time_iso8601",'
  '"remote_addr":"$remote_addr",'
  '"method":"$request_method",'
  '"uri":"$request_uri",'
  '"status":$status,'
  '"bytes_sent":$bytes_sent,'
  '"request_time":$request_time,'
  '"referer":"$http_referer",'
  '"user_agent":"$http_user_agent"'
  '}';

access_log /var/log/nginx/app_access.log json_combined;

12.3 Centralized logs with Vector + ClickHouse

Vector is a high-performance log pipeline agent. Install:

curl --proto '=https' --tlsv1.2 -sSfL https://sh.vector.dev | bash

Create /etc/vector/vector.yaml:

sources:
  journald:
    type: journald
    include_units:
      - go-service
      - nginx
      - postgresql

  nginx_access:
    type: file
    include:
      - /var/log/nginx/app_access.log
    data_dir: /var/lib/vector

transforms:
  parse_nginx:
    type: remap
    inputs: [nginx_access]
    source: |
      . = parse_json!(.message)
      .source = "nginx"

  parse_journald:
    type: remap
    inputs: [journald]
    source: |
      .source = .unit
      .level  = .PRIORITY

sinks:
  clickhouse_logs:
    type: clickhouse
    inputs: [parse_nginx, parse_journald]
    endpoint: http://127.0.0.1:8123
    database: analytics
    table: logs
    auth:
      strategy: basic
      user: default
      password: your_clickhouse_password
    encoding:
      timestamp_format: unix
    compression: gzip

Create the ClickHouse logs table:

CREATE TABLE analytics.logs (
    timestamp   DateTime,
    source      LowCardinality(String),
    level       LowCardinality(String),
    message     String,
    fields      String    -- JSON
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (source, timestamp)
TTL timestamp + INTERVAL 90 DAY;
sudo systemctl enable --now vector

Now query logs directly from ClickHouse:

SELECT timestamp, source, message
FROM analytics.logs
WHERE source = 'nginx' AND timestamp > now() - INTERVAL 1 HOUR
ORDER BY timestamp DESC
LIMIT 100;

12.4 Application logging best practices

Node.js — use pino for structured JSON logs:

import pino from 'pino'

const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  timestamp: pino.stdTimeFunctions.isoTime,
  redact: ['req.headers.authorization', 'body.password'],
})

export default logger

Go — use log/slog (stdlib, Go 1.21+):

import "log/slog"

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))
slog.SetDefault(logger)

slog.Info("request handled",
    "method", r.Method,
    "path",   r.URL.Path,
    "status", status,
    "latency", duration,
)

13. Monitoring (optional but recommended)

13.1 Prometheus + node_exporter

# node_exporter — system metrics
wget https://github.com/prometheus/node_exporter/releases/download/v1.8.2/node_exporter-1.8.2.linux-amd64.tar.gz
tar xzf node_exporter-1.8.2.linux-amd64.tar.gz
sudo mv node_exporter-1.8.2.linux-amd64/node_exporter /usr/local/bin/
sudo useradd -rs /bin/false node_exporter

Create /etc/systemd/system/node_exporter.service:

[Unit]
Description=Prometheus Node Exporter
After=network.target

[Service]
User=node_exporter
ExecStart=/usr/local/bin/node_exporter
Restart=always
NoNewPrivileges=yes

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now node_exporter
# Metrics available at http://127.0.0.1:9100/metrics

Add Postgres exporter, Redis exporter similarly. Bind all exporters to 127.0.0.1.

13.2 Grafana

sudo apt install -y apt-transport-https software-properties-common
wget -q -O - https://packages.grafana.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/grafana-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/grafana-keyring.gpg] https://packages.grafana.com/oss/deb stable main" \
  | sudo tee /etc/apt/sources.list.d/grafana.list
sudo apt update && sudo apt install -y grafana
sudo systemctl enable --now grafana-server

Grafana listens on port 3000. Proxy it through Nginx (same pattern as the API above), then add ClickHouse as a data source using the grafana-clickhouse-datasource plugin.


14. Deployment Workflow

A minimal zero-downtime deploy script for the Go service:

#!/usr/bin/env bash
# deploy.sh
set -euo pipefail

APP_DIR=/home/deploy/apps/go
BINARY=service

echo "Building..."
GOOS=linux GOARCH=amd64 go build -o bin/${BINARY} ./cmd/${BINARY}

echo "Uploading..."
scp -P 2222 bin/${BINARY} deploy@YOUR_SERVER_IP:${APP_DIR}/${BINARY}.new

echo "Swapping..."
ssh -p 2222 deploy@YOUR_SERVER_IP "
  mv ${APP_DIR}/${BINARY}.new ${APP_DIR}/${BINARY}
  sudo systemctl restart go-service
  systemctl is-active go-service
"
echo "Done."

For Node.js with PM2:

pm2 reload ecosystem.config.js --update-env

PM2 reload is zero-downtime — it restarts workers one at a time.


15. Checklist Summary

Before calling this production-ready:

  • SSH on non-default port, root login disabled, key-only auth
  • UFW enabled with minimal open ports
  • Fail2ban active on SSH
  • All services (PG, Redis, ClickHouse) bound to 127.0.0.1 only
  • Postgres using scram-sha-256, no trust auth
  • Redis requirepass set, dangerous commands renamed
  • ClickHouse password set, remote access disabled
  • TLS certificate issued and auto-renewing
  • Security headers on Nginx (HSTS, CSP, X-Frame-Options)
  • Secrets in EnvironmentFile, not in code or PM2 config
  • Unattended security upgrades enabled
  • Kernel hardening sysctl applied
  • Application logs structured (JSON) and shipping to ClickHouse
  • Backups scheduled and tested
  • node_exporter + Grafana dashboard live
  • Rate limiting on API endpoints
  • Redpanda topics created, retention set
  • NATS JetStream streams defined
  • Garage layout applied, buckets created, admin key rotated
  • Typesense collection schema created, search-only key issued
  • OpenTelemetry collector shipping to backend

16. Redpanda (Kafka-Compatible Streaming)

Redpanda is a drop-in Kafka replacement written in C++. No JVM, no ZooKeeper, single binary, dramatically simpler to operate on a VPS.

16.1 Install

curl -1sLf 'https://dl.redpanda.com/nzc4ZYQK3WRGd9sy/redpanda/cfg/setup/bash.deb.sh' \
  | sudo -E bash
sudo apt install -y redpanda
sudo systemctl enable --now redpanda

16.2 Configure

sudo nano /etc/redpanda/redpanda.yaml

Key settings for a single-node VPS:

redpanda:
  data_directory: /var/lib/redpanda/data
  seed_servers: []
  rpc_server:
    address: 127.0.0.1
    port: 33145
  kafka_api:
    - address: 127.0.0.1
      port: 9092
  admin:
    - address: 127.0.0.1
      port: 9644
  developer_mode: false
  auto_create_topics_enabled: false   # explicit topic creation only
sudo systemctl restart redpanda
rpk cluster info

16.3 Create topics

# rpk is the Redpanda CLI
rpk topic create orders --partitions 6 --replicas 1
rpk topic create events --partitions 12 --replicas 1
rpk topic create dlq    --partitions 3  --replicas 1

# Set retention (7 days by bytes and time)
rpk topic alter-config orders \
  --set retention.ms=604800000 \
  --set retention.bytes=1073741824

16.4 ACL / security

Enable SASL authentication:

redpanda:
  kafka_api:
    - address: 127.0.0.1
      port: 9092
      authentication_method: sasl
  sasl_mechanisms:
    - SCRAM-SHA-256

rpk:
  kafka_api:
    sasl:
      user: admin
      password: strong_password
      mechanism: SCRAM-SHA-256
rpk acl user create app-producer --password app_pass --mechanism SCRAM-SHA-256
rpk acl create --allow-principal app-producer \
  --operation write --topic orders

16.5 Produce and consume — Node.js

npm install kafkajs
import { Kafka } from 'kafkajs'

const kafka = new Kafka({
  clientId: 'my-app',
  brokers: ['127.0.0.1:9092'],
  sasl: { mechanism: 'scram-sha-256', username: 'app-producer', password: 'app_pass' },
})

const producer = kafka.producer()
await producer.connect()
await producer.send({
  topic: 'orders',
  messages: [{ key: orderId, value: JSON.stringify(order) }],
})
await producer.disconnect()

Consumer:

const consumer = kafka.consumer({ groupId: 'order-processor' })
await consumer.connect()
await consumer.subscribe({ topic: 'orders', fromBeginning: false })
await consumer.run({
  eachMessage: async ({ message }) => {
    const order = JSON.parse(message.value.toString())
    await processOrder(order)
  },
})

16.6 Produce and consume — Go

go get github.com/twmb/franz-go/pkg/kgo
import "github.com/twmb/franz-go/pkg/kgo"

cl, _ := kgo.NewClient(
    kgo.SeedBrokers("127.0.0.1:9092"),
    kgo.SASL(scram.Auth{User: "app-producer", Pass: "app_pass"}.AsSha256Mechanism()),
)
defer cl.Close()

// Produce
cl.Produce(ctx, &kgo.Record{
    Topic: "orders",
    Key:   []byte(orderID),
    Value: payload,
}, nil)

// Consume
cl.AddConsumeTopics("orders")
for {
    fetches := cl.PollFetches(ctx)
    fetches.EachRecord(func(r *kgo.Record) {
        handleOrder(r.Value)
    })
}

16.7 Redpanda Console (optional UI)

sudo apt install -y redpanda-console
sudo nano /etc/redpanda-console/config.yaml
kafka:
  brokers: ["127.0.0.1:9092"]
  sasl:
    enabled: true
    username: admin
    password: strong_password
    mechanism: SCRAM-SHA-256
server:
  listenPort: 8080
  listenAddress: 127.0.0.1

Proxy through Nginx at /console/ (internal access only, or behind auth).


17. NATS (Pub/Sub + Job Queue + KV Store)

NATS covers lightweight pub/sub, request/reply, and — with JetStream — durable streams and a key/value store. Use it when Redpanda feels heavy, or for inter-service RPC.

17.1 Install

# Download latest release
NATS_VERSION=2.10.18
wget https://github.com/nats-io/nats-server/releases/download/v${NATS_VERSION}/nats-server-v${NATS_VERSION}-linux-amd64.zip
unzip nats-server-v${NATS_VERSION}-linux-amd64.zip
sudo mv nats-server-v${NATS_VERSION}-linux-amd64/nats-server /usr/local/bin/
nats-server --version

17.2 Configure

sudo mkdir -p /etc/nats /var/lib/nats
sudo useradd -rs /bin/false nats
sudo nano /etc/nats/server.conf
port: 4222
host: "127.0.0.1"
http_port: 8222           # monitoring (localhost only)

authorization {
  token: "your_nats_token"
}

jetstream {
  store_dir: "/var/lib/nats/jetstream"
  max_memory_store: 512M
  max_file_store:   10G
}

Create /etc/systemd/system/nats.service:

[Unit]
Description=NATS Server
After=network.target

[Service]
User=nats
ExecStart=/usr/local/bin/nats-server -c /etc/nats/server.conf
Restart=always
RestartSec=5
LimitNOFILE=65536
NoNewPrivileges=yes

[Install]
WantedBy=multi-user.target
sudo chown -R nats:nats /var/lib/nats
sudo systemctl daemon-reload
sudo systemctl enable --now nats

17.3 Create JetStream streams

Install the nats CLI:

go install github.com/nats-io/natscli/nats@latest
# or download binary from https://github.com/nats-io/natscli/releases
# Connect with token
nats context save local --server nats://127.0.0.1:4222 --token your_nats_token
nats context select local

# Create a stream
nats stream add JOBS \
  --subjects "jobs.>" \
  --storage file \
  --retention work \
  --max-age 7d \
  --discard old \
  --replicas 1

# Create a consumer (pull-based worker)
nats consumer add JOBS worker \
  --pull \
  --deliver all \
  --ack explicit \
  --max-deliver 3 \
  --backoff linear

17.4 Publish and consume — Node.js

npm install nats
import { connect, StringCodec } from 'nats'

const nc = await connect({ servers: '127.0.0.1:4222', token: 'your_nats_token' })
const sc = StringCodec()
const js = nc.jetstream()

// Publish to stream
await js.publish('jobs.email', sc.encode(JSON.stringify({ to: 'user@example.com' })))

// Worker consume
const consumer = await js.consumers.get('JOBS', 'worker')
const messages = await consumer.consume()
for await (const msg of messages) {
  await processJob(JSON.parse(sc.decode(msg.data)))
  msg.ack()
}

17.5 KV store

NATS JetStream includes a distributed key/value store — useful for feature flags, distributed locks, and config:

nats kv add CONFIG --history 5 --ttl 1h
nats kv put CONFIG feature.dark_mode true
nats kv get CONFIG feature.dark_mode
const kv = await js.views.kv('CONFIG')
await kv.put('feature.dark_mode', sc.encode('true'))
const entry = await kv.get('feature.dark_mode')

18. Garage (S3-Compatible Object Storage)

Why not MinIO? MinIO re-licensed to AGPLv3 in 2021, making it incompatible with most commercial products without a paid license. It is also cluster-oriented — its single-node mode is unsupported in production. Garage is MIT licensed, written in Rust, explicitly designed for small/single-node deployments, and exposes an identical S3 API so every SDK that works against AWS S3 works unchanged.

Garage stores files, images, backups, and any blob. Your application code stays AWS-S3-compatible — swap the endpoint URL to move to real S3 or Cloudflare R2 later.

18.1 Install

GARAGE_VERSION=1.0.1
wget https://garagehq.deuxfleurs.fr/_releases/v${GARAGE_VERSION}/x86_64-unknown-linux-musl/garage
chmod +x garage
sudo mv garage /usr/local/bin/

sudo useradd -rs /bin/false garage
sudo mkdir -p /var/lib/garage/meta /var/lib/garage/data
sudo chown -R garage:garage /var/lib/garage

18.2 Configure

sudo mkdir -p /etc/garage
sudo nano /etc/garage/garage.toml
metadata_dir = "/var/lib/garage/meta"
data_dir     = "/var/lib/garage/data"
db_engine    = "lmdb"

replication_factor = 1          # single node

[s3_api]
s3_region = "garage"            # arbitrary, must match in SDK
api_bind_addr = "127.0.0.1:3900"
root_domain = ".s3.local"

[s3_web]
bind_addr = "127.0.0.1:3902"
root_domain = ".web.garage"
index = "index.html"

[admin]
api_bind_addr = "127.0.0.1:3903"

Create /etc/systemd/system/garage.service:

[Unit]
Description=Garage S3-Compatible Object Store
After=network.target

[Service]
User=garage
ExecStart=/usr/local/bin/garage -c /etc/garage/garage.toml server
Restart=always
RestartSec=5
LimitNOFILE=65536
NoNewPrivileges=yes
PrivateTmp=yes

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now garage

18.3 Bootstrap the cluster (single node)

# Get the local node ID
garage -c /etc/garage/garage.toml status

# Apply layout (capacity in GB)
NODE_ID=$(garage -c /etc/garage/garage.toml status | awk '/UNCONFIGURED/{print $1}')
garage -c /etc/garage/garage.toml layout assign \
  -z dc1 -c 100G ${NODE_ID}
garage -c /etc/garage/garage.toml layout apply --version 1
garage -c /etc/garage/garage.toml status

18.4 Create keys and buckets

# Create an access key
garage -c /etc/garage/garage.toml key create app-key
# Outputs: Key ID + Secret Key — save these

KEY_ID=<your_key_id>

# Create buckets
garage -c /etc/garage/garage.toml bucket create uploads
garage -c /etc/garage/garage.toml bucket create backups
garage -c /etc/garage/garage.toml bucket create avatars

# Grant access
garage -c /etc/garage/garage.toml bucket allow uploads \
  --read --write --owner --key ${KEY_ID}
garage -c /etc/garage/garage.toml bucket allow backups \
  --read --write --owner --key ${KEY_ID}
garage -c /etc/garage/garage.toml bucket allow avatars \
  --read --write --owner --key ${KEY_ID}

# Public-read for avatars (static files via web endpoint)
garage -c /etc/garage/garage.toml bucket website --allow avatars

18.5 Use from Node.js

The AWS S3 SDK works unchanged — only the endpoint and region differ:

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

const s3 = new S3Client({
  endpoint: 'http://127.0.0.1:3900',
  region: 'garage',                   // must match garage.toml s3_region
  credentials: { accessKeyId: 'GK...', secretAccessKey: '...' },
  forcePathStyle: true,               // required for path-style S3
})

// Upload
await s3.send(new PutObjectCommand({
  Bucket: 'uploads',
  Key: `users/${userId}/${filename}`,
  Body: fileBuffer,
  ContentType: 'image/jpeg',
}))

// Pre-signed URL (expiry 1 hour)
const url = await getSignedUrl(s3, new GetObjectCommand({
  Bucket: 'uploads',
  Key: `users/${userId}/${filename}`,
}), { expiresIn: 3600 })

18.6 Use from Go

go get github.com/aws/aws-sdk-go-v2/service/s3
import (
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/credentials"
    "github.com/aws/aws-sdk-go-v2/service/s3"
)

cfg, _ := config.LoadDefaultConfig(ctx,
    config.WithRegion("garage"),
    config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
        "GK...", "secret...", "",
    )),
    config.WithEndpointResolverWithOptions(
        aws.EndpointResolverWithOptionsFunc(func(service, region string, opts ...interface{}) (aws.Endpoint, error) {
            return aws.Endpoint{URL: "http://127.0.0.1:3900", HostnameImmutable: true}, nil
        }),
    ),
)

client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })

// Upload
_, err := client.PutObject(ctx, &s3.PutObjectInput{
    Bucket:      aws.String("uploads"),
    Key:         aws.String(fmt.Sprintf("users/%s/%s", userID, filename)),
    Body:        reader,
    ContentType: aws.String("image/jpeg"),
})

18.7 Backup Postgres to Garage

#!/usr/bin/env bash
set -euo pipefail
DATE=$(date +%Y%m%d_%H%M%S)
TMPFILE=$(mktemp)

PGPASSWORD="strong_random_password" pg_dump \
  -h 127.0.0.1 -U appuser appdb | gzip > "$TMPFILE"

AWS_ACCESS_KEY_ID=GK... \
AWS_SECRET_ACCESS_KEY=secret... \
aws s3 cp "$TMPFILE" "s3://backups/postgres/appdb_${DATE}.sql.gz" \
  --endpoint-url http://127.0.0.1:3900 \
  --region garage

rm "$TMPFILE"

# Prune backups older than 30 days
aws s3 ls s3://backups/postgres/ \
  --endpoint-url http://127.0.0.1:3900 --region garage \
  | awk '{print $4}' \
  | while read key; do
      ts=$(echo "$key" | grep -oP '\d{8}')
      [ "$(date -d "$ts" +%s 2>/dev/null)" -lt "$(date -d '30 days ago' +%s)" ] \
        && aws s3 rm "s3://backups/postgres/$key" \
             --endpoint-url http://127.0.0.1:3900 --region garage
    done

19. Typesense (Full-Text Search)

Typesense is a fast, typo-tolerant search engine written in C++. It beats Elasticsearch on RAM, starts in under a second, and has a simpler API than both Elasticsearch and Meilisearch. Add it when Postgres tsvector / ILIKE queries become a bottleneck or you need ranking, facets, and highlighting.

19.1 Install

TYPESENSE_VERSION=27.1
wget https://dl.typesense.org/releases/${TYPESENSE_VERSION}/typesense-server-${TYPESENSE_VERSION}-amd64.deb
sudo dpkg -i typesense-server-${TYPESENSE_VERSION}-amd64.deb

The package installs a systemd service and creates /etc/typesense/typesense-server.ini.

19.2 Configure

sudo nano /etc/typesense/typesense-server.ini
[server]
api-address    = 127.0.0.1
api-port       = 8108
data-dir       = /var/lib/typesense
api-key        = your_admin_api_key      # change this
log-dir        = /var/log/typesense
enable-cors    = false
sudo systemctl enable --now typesense-server
curl -s http://127.0.0.1:8108/health   # → {"ok":true}

19.3 Create collection and schema

Typesense uses collections (equivalent to indexes). Define the schema upfront:

curl -X POST 'http://127.0.0.1:8108/collections' \
  -H 'X-TYPESENSE-API-KEY: your_admin_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "products",
    "fields": [
      {"name": "id",          "type": "string"},
      {"name": "name",        "type": "string"},
      {"name": "description", "type": "string"},
      {"name": "tags",        "type": "string[]", "facet": true},
      {"name": "category",    "type": "string",   "facet": true},
      {"name": "price",       "type": "float",    "sort":  true},
      {"name": "in_stock",    "type": "bool",     "facet": true},
      {"name": "created_at",  "type": "int64",    "sort":  true}
    ],
    "default_sorting_field": "created_at"
  }'

19.4 Scoped API keys

Create a search-only key scoped to specific collections. Never ship the admin key to clients:

curl -X POST 'http://127.0.0.1:8108/keys' \
  -H 'X-TYPESENSE-API-KEY: your_admin_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "description": "Search-only key for frontend",
    "actions": ["documents:search"],
    "collections": ["products"]
  }'

For server-to-server use (indexing), create a write key scoped to specific collections and actions.

19.5 Use from Node.js

npm install typesense
import Typesense from 'typesense'

const client = new Typesense.Client({
  nodes: [{ host: '127.0.0.1', port: 8108, protocol: 'http' }],
  apiKey: 'your_admin_api_key',   // use search-only key on frontend
  connectionTimeoutSeconds: 2,
})

// Index documents
await client.collections('products').documents().import(products, { action: 'upsert' })

// Search
const results = await client.collections('products').documents().search({
  q:               'wireless headphones',
  query_by:        'name,description,tags',
  filter_by:       'category:=electronics && in_stock:=true',
  sort_by:         'price:asc',
  facet_by:        'category,tags',
  per_page:        20,
  typo_tokens_threshold: 1,
})

19.6 Use from Go

go get github.com/typesense/typesense-go/v3
import (
    "github.com/typesense/typesense-go/v3/typesense"
    "github.com/typesense/typesense-go/v3/typesense/api"
)

client := typesense.NewClient(
    typesense.WithServer("http://127.0.0.1:8108"),
    typesense.WithAPIKey("your_admin_api_key"),
)

// Upsert document
_, err := client.Collection("products").Documents().Upsert(ctx, &ProductDoc{
    ID:       product.ID,
    Name:     product.Name,
    Price:    product.Price,
    Category: product.Category,
    InStock:  product.InStock,
})

// Search
params := &api.SearchCollectionParams{
    Q:          "wireless headphones",
    QueryBy:    "name,description",
    FilterBy:   typesense.String("category:=electronics"),
    SortBy:     typesense.String("price:asc"),
    PerPage:    typesense.Int(20),
}
results, err := client.Collection("products").Documents().Search(ctx, params)

19.7 Keep index in sync with Postgres

Simple pattern — write to Postgres first, then upsert to Typesense in the same request handler:

await db.query('INSERT INTO products ...', values)
await client.collections('products').documents().upsert({ id, name, description, category, price })

Robust pattern — publish a Redpanda event on every write; a dedicated indexer service consumes it and calls Typesense. Decoupled, retryable, no blocking the API path.

Bulk re-index from Postgres:

const { rows } = await db.query('SELECT id, name, description, category, price, in_stock FROM products')
// Typesense import accepts up to 40 docs/batch by default; chunking is handled internally
await client.collections('products').documents().import(rows, { action: 'upsert' })

20. OpenTelemetry (Traces + Metrics)

OpenTelemetry is vendor-neutral instrumentation. Traces show you exactly where time is spent across services; metrics give you RED (Rate, Errors, Duration) dashboards.

20.1 OTel Collector

The collector receives spans/metrics from your apps and fans them out to backends (Prometheus, Grafana Tempo, Jaeger, or a cloud provider).

wget https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v0.105.0/otelcol-contrib_0.105.0_linux_amd64.deb
sudo dpkg -i otelcol-contrib_0.105.0_linux_amd64.deb
sudo systemctl enable otelcol-contrib
sudo nano /etc/otelcol-contrib/config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 127.0.0.1:4317
      http:
        endpoint: 127.0.0.1:4318

processors:
  batch:
    timeout: 5s
    send_batch_size: 1024
  memory_limiter:
    limit_mib: 256

exporters:
  prometheus:
    endpoint: "127.0.0.1:8889"   # scrape this from Prometheus
  debug:
    verbosity: basic

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [debug]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]
sudo systemctl restart otelcol-contrib

20.2 Instrument Node.js

npm install @opentelemetry/sdk-node \
            @opentelemetry/auto-instrumentations-node \
            @opentelemetry/exporter-trace-otlp-http \
            @opentelemetry/exporter-metrics-otlp-http

Create instrumentation.js — loaded before your app:

import { NodeSDK } from '@opentelemetry/sdk-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'

const sdk = new NodeSDK({
  serviceName: 'api',
  traceExporter: new OTLPTraceExporter({ url: 'http://127.0.0.1:4318/v1/traces' }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({ url: 'http://127.0.0.1:4318/v1/metrics' }),
    exportIntervalMillis: 15_000,
  }),
  instrumentations: [getNodeAutoInstrumentations({
    '@opentelemetry/instrumentation-fs': { enabled: false }, // too noisy
  })],
})

sdk.start()
process.on('SIGTERM', () => sdk.shutdown())

Start your app with:

node --import ./instrumentation.js dist/server.js

This auto-instruments HTTP, Express/Fastify, Postgres (pg), Redis (ioredis), Kafka (kafkajs) — all without code changes.

20.3 Instrument Go

go get go.opentelemetry.io/otel \
       go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc \
       go.opentelemetry.io/otel/sdk/trace \
       go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
    exp, err := otlptracegrpc.New(ctx,
        otlptracegrpc.WithEndpoint("127.0.0.1:4317"),
        otlptracegrpc.WithInsecure(),
    )
    if err != nil {
        return nil, err
    }
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exp),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceName("go-service"),
        )),
    )
    otel.SetTracerProvider(tp)
    return tp, nil
}

// Wrap your HTTP mux
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler)
http.ListenAndServe(":3002", otelhttp.NewHandler(mux, "go-service"))

Add a custom span:

tracer := otel.Tracer("go-service")
ctx, span := tracer.Start(ctx, "processOrder")
defer span.End()

span.SetAttributes(attribute.String("order.id", orderID))

20.4 Add Grafana Tempo for trace storage (optional)

wget https://github.com/grafana/tempo/releases/download/v2.5.0/tempo_2.5.0_linux_amd64.deb
sudo dpkg -i tempo_2.5.0_linux_amd64.deb
sudo systemctl enable --now tempo

Update the OTel collector to export traces to Tempo instead of (or in addition to) debug:

exporters:
  otlp/tempo:
    endpoint: "127.0.0.1:4317"
    tls:
      insecure: true

service:
  pipelines:
    traces:
      exporters: [otlp/tempo]

Add Tempo as a data source in Grafana (URL: http://127.0.0.1:3200). You can now jump from a slow API log line to its full distributed trace in one click.


21. Service Communication Patterns

With all these services running, here’s how they fit together:

Browser

  └─► Nginx (443) ──► React SPA (static)
                  ──► /api/*  ──► Node.js :3001

                                     ├─ Postgres (queries)
                                     ├─ Redis (cache / sessions)
                                     ├─ Garage (file uploads)
                                     ├─ Typesense (search)
                                     └─ Redpanda (produce events)

                                   ┌──────────┘

                  ──► /internal/* ──► Go :3002

                                       ├─ Postgres (writes)
                                       ├─ ClickHouse (analytics writes)
                                       ├─ Redpanda (consume events)
                                       └─ NATS (job dispatch)

                                        NATS consumers
                                        (worker processes)
                                           ├─ send email
                                           ├─ resize image → Garage
                                           └─ update search index → Typesense

Key rules:

  • Every service binds to 127.0.0.1. Nginx is the only public listener.
  • Node.js handles user-facing API; Go handles heavy background workloads and analytics ingestion.
  • Redpanda decouples producers from consumers — a slow consumer doesn’t slow the API.
  • NATS handles short-lived jobs (send email, webhook delivery); Redpanda handles durable event streams (audit log, analytics).
  • ClickHouse is write-mostly from Go; query it from Grafana and internal dashboards, never from the user-facing API path.

Further Reading