Deploying logstream-server with Docker

This guide covers deploying the logstream-server using Docker and Docker Compose, suitable for any host that can run Docker: a Linux VPS, a home server, or a cloud VM. HTTPS is handled by a dedicated Nginx + Certbot container — no host-level dependencies beyond Docker itself.


Prerequisites

  • Any Linux host with Docker and Docker Compose installed (instructions below)
  • A domain name with an A record pointing to the server’s public IP
  • Ports 80 and 443 open on the host firewall

1. Install Docker

Ubuntu / Debian

# Remove any old Docker packages
sudo apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true

# Install prerequisites
sudo apt update
sudo apt install -y ca-certificates curl gnupg

# Add Docker's official GPG key and repository
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
    sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
    https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
    sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

# Allow your user to run Docker without sudo
sudo usermod -aG docker $USER
newgrp docker

# Verify
docker --version
docker compose version

Windows / macOS

Install Docker Desktop and ensure it is running before continuing.


2. Clone the repository

git clone https://github.com/guibranco/logstream-server.git
cd logstream-server

3. Configure the environment

cp .env.example .env
nano .env

Set your values:

HTTP_PORT=8081
WS_PORT=8080

# Write key — used by your applications to POST logs
API_SECRET=<generate-a-strong-secret>

# Read key — used by the UI and authorised humans to view logs
UI_SECRET=<generate-another-strong-secret>

# Storage backend
STORAGE_TYPE=mariadb   # or "file"
LOG_PATH=./storage/logs

# MariaDB (used when STORAGE_TYPE=mariadb)
DB_HOST=db
DB_PORT=3306
DB_NAME=logservice
DB_USER=logservice
DB_PASS=<generate-a-strong-db-password>

Generate strong secrets:

openssl rand -base64 32

4. Quick start (HTTP only, no reverse proxy)

Use this for local development or testing only. Skip to step 5 for a production HTTPS setup.

The repository ships with a docker-compose.yml that starts the app and a MariaDB instance:

docker compose up -d

Check everything is running:

docker compose ps
docker compose logs -f app

Verify the health endpoint:

curl -s http://localhost:8081/api/health | python3 -m json.tool

5. Production setup with HTTPS (Nginx + Certbot)

For production you need a reverse proxy that handles TLS termination. The cleanest Docker approach uses dedicated Nginx and Certbot containers alongside the app.

5a. Create the production Compose file

Create a new file docker-compose.prod.yml next to the existing one:

nano docker-compose.prod.yml
services:

  # ── Application ──────────────────────────────────────────────────────────────
  app:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      HTTP_PORT:    8081
      WS_PORT:      8080
      API_SECRET:   ${API_SECRET}
      UI_SECRET:    ${UI_SECRET}
      STORAGE_TYPE: ${STORAGE_TYPE:-mariadb}
      DB_HOST:      db
      DB_PORT:      3306
      DB_NAME:      ${DB_NAME:-logservice}
      DB_USER:      ${DB_USER:-logservice}
      DB_PASS:      ${DB_PASS}
      LOG_PATH:     /app/storage/logs
    volumes:
      - log_data:/app/storage/logs
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test:         ["CMD", "curl", "-fsS", "http://localhost:8081/api/health"]
      interval:     10s
      timeout:      5s
      start_period: 20s
      retries:      5
    restart: unless-stopped
    # Not exposed to the host — Nginx reaches it on the internal network
    expose:
      - "8081"
      - "8080"

  # ── MariaDB ──────────────────────────────────────────────────────────────────
  db:
    image: mariadb:11
    environment:
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASS:-rootsecret}
      MARIADB_DATABASE:      ${DB_NAME:-logservice}
      MARIADB_USER:          ${DB_USER:-logservice}
      MARIADB_PASSWORD:      ${DB_PASS}
    volumes:
      - db_data:/var/lib/mysql
      - ./migrations:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test:         ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval:     10s
      timeout:      5s
      start_period: 30s
      retries:      10
    restart: unless-stopped

  # ── Nginx reverse proxy ───────────────────────────────────────────────────────
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - certbot_www:/var/www/certbot:ro
      - certbot_certs:/etc/letsencrypt:ro
    depends_on:
      - app
    restart: unless-stopped

  # ── Certbot (certificate management) ─────────────────────────────────────────
  certbot:
    image: certbot/certbot
    volumes:
      - certbot_www:/var/www/certbot
      - certbot_certs:/etc/letsencrypt
    # Renew automatically every 12 hours; exits immediately if nothing is due
    entrypoint: >
      /bin/sh -c "trap exit TERM;
      while :; do
        certbot renew --webroot --webroot-path=/var/www/certbot --quiet;
        sleep 12h & wait $${!};
      done"
    restart: unless-stopped

volumes:
  db_data:
  log_data:
  certbot_www:
  certbot_certs:

5b. Create the Nginx configuration directory

mkdir -p nginx/conf.d

Create a temporary HTTP-only config to allow Certbot to issue the first certificate. Replace logs.yourdomain.com with your actual domain:

nano nginx/conf.d/logstream.conf
server {
    listen 80;
    listen [::]:80;
    server_name logs.yourdomain.com;

    # Certbot ACME challenge
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Temporary: proxy to the app while we wait for the certificate
    location /api/ {
        proxy_pass http://app:8081;
        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;
    }
}

5c. Start the stack and issue the certificate

# Start Nginx and the app (Certbot starts too but will idle until triggered)
docker compose -f docker-compose.prod.yml up -d

# Check all containers are running
docker compose -f docker-compose.prod.yml ps

# Issue the certificate — replace the email and domain
docker compose -f docker-compose.prod.yml run --rm certbot certonly \
    --webroot \
    --webroot-path=/var/www/certbot \
    --email your@email.com \
    --agree-tos \
    --no-eff-email \
    -d logs.yourdomain.com

5d. Switch Nginx to the full HTTPS config

Now that the certificate exists, replace the Nginx config with the full HTTPS version:

nano nginx/conf.d/logstream.conf
# HTTP → HTTPS redirect
server {
    listen 80;
    listen [::]:80;
    server_name logs.yourdomain.com;

    # Keep Certbot challenge working for renewals
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name logs.yourdomain.com;

    # ── SSL ──────────────────────────────────────────────────────────────────
    ssl_certificate     /etc/letsencrypt/live/logs.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/logs.yourdomain.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;

    # ── Security headers ─────────────────────────────────────────────────────
    add_header X-Frame-Options        "SAMEORIGIN"    always;
    add_header X-Content-Type-Options "nosniff"       always;
    add_header X-XSS-Protection       "1; mode=block" always;
    add_header Referrer-Policy        "no-referrer"   always;
    add_header Strict-Transport-Security "max-age=63072000" always;

    # ── HTTP API → app:8081 ───────────────────────────────────────────────────
    location /api/ {
        proxy_pass         http://app:8081;
        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;

        proxy_read_timeout    60s;
        proxy_send_timeout    60s;
        proxy_connect_timeout 10s;
    }

    # ── WebSocket → app:8080 ─────────────────────────────────────────────────
    location /ws {
        proxy_pass         http://app:8080;
        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_read_timeout  3600s;
        proxy_send_timeout  3600s;
    }
}

Reload Nginx to apply the new config:

docker compose -f docker-compose.prod.yml exec nginx nginx -s reload

6. Final end-to-end verification

# Health check over HTTPS
curl -s https://logs.yourdomain.com/api/health | python3 -m json.tool

# Confirm HTTP redirects to HTTPS
curl -sI http://logs.yourdomain.com

# Send a test log entry
curl -s -X POST https://logs.yourdomain.com/api/logs \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer <API_SECRET>" \
    -H "User-Agent: DockerTest/1.0" \
    -d '{
        "app_key":  "deploy-test",
        "app_id":   "docker",
        "level":    "info",
        "category": "deployment",
        "message":  "Server deployed successfully in Docker"
    }' | python3 -m json.tool

# Read it back
curl -s "https://logs.yourdomain.com/api/logs?app_key=deploy-test" \
    -H "Authorization: Bearer <UI_SECRET>" | python3 -m json.tool

Day-to-day operations

View live logs

# All containers
docker compose -f docker-compose.prod.yml logs -f

# App only
docker compose -f docker-compose.prod.yml logs -f app

# Nginx only
docker compose -f docker-compose.prod.yml logs -f nginx

Restart the service

docker compose -f docker-compose.prod.yml restart app

Deploy a new version

# Pull latest code
git pull origin main

# Rebuild only the app image (db and nginx are unchanged)
docker compose -f docker-compose.prod.yml build app

# Recreate the app container with zero-downtime (Compose handles the swap)
docker compose -f docker-compose.prod.yml up -d --no-deps app

Run database migrations manually

docker compose -f docker-compose.prod.yml exec app \
    bash Tools/db-migration.sh migrations db logservice logservice

Check certificate status

docker compose -f docker-compose.prod.yml exec certbot certbot certificates

Force a renewal test:

docker compose -f docker-compose.prod.yml run --rm certbot certbot renew --dry-run

Stop everything

docker compose -f docker-compose.prod.yml down

# Stop AND remove all volumes (wipes database and logs — irreversible)
docker compose -f docker-compose.prod.yml down -v

Environment variable reference

Add these to your .env file (or pass them directly to docker compose):

Variable Required Description
API_SECRET Write key for POST /api/logs
UI_SECRET Read key for GET /api/logs and WebSocket
STORAGE_TYPE mariadb or file
DB_PASS ✅ (mariadb) MariaDB user password
DB_ROOT_PASS ✅ (mariadb) MariaDB root password
DB_NAME Database name (default: logservice)
DB_USER Database user (default: logservice)
HTTP_PORT Internal HTTP port (default: 8081)
WS_PORT Internal WebSocket port (default: 8080)
LOG_PATH Log path when using file storage (default: ./storage/logs)

Troubleshooting

Symptom Likely cause Fix
App container exits immediately Bad .env values or DB not ready docker compose logs app to inspect the error
db container not healthy MariaDB still initialising Wait 30–60 s on first start — it runs the migration SQL
Certbot fails to issue certificate DNS not propagated or port 80 blocked Verify dig logs.yourdomain.com points to the correct IP and port 80 is open
Nginx returns 502 Bad Gateway App container not healthy yet docker compose ps — wait for app health check to pass
401 Unauthorized on POST /api/logs Wrong or missing API_SECRET Check Authorization: Bearer <API_SECRET> header
401 Unauthorized on GET /api/logs Wrong or missing UI_SECRET Check Authorization: Bearer <UI_SECRET> header
WebSocket connection rejected Wrong or missing token Connect with wss://domain/ws?token=<UI_SECRET>
Certificate not renewing Certbot container not running docker compose -f docker-compose.prod.yml up -d certbot
Old image still running after git pull Image not rebuilt Always run build app before up -d when code changes