Skip to content

Docker & Environment Setup

This guide explains how to configure and deploy the Mdlwr application stack on your production server using Docker Compose.


1. Prerequisites

Before proceeding with the steps below, ensure that your target server has the following installed:

  • Docker Engine (latest stable version)
  • Docker Compose (v2 plugin)

Installation Guide (OS Specific)

If you do not have Docker installed, please follow the official installation guide from Docker's documentation based on your operating system:


2. Directory Structure Setup

On your target server (e.g., VPS or local client environment), create a base directory to store the configuration, environment files, and Docker volumes.

Run the following commands in your terminal:

sudo mkdir -p /opt/mdlwr/{volumes/postgres,volumes/redis,nginx,logs,tmp/incoming,apps/static/exports,apps/static/custom}
cd /opt/mdlwr

3. Environment Configuration (.env)

Create an .env file inside your base directory (/opt/mdlwr/.env) to store sensitive environment variables, database credentials, and base paths.

⚠️ Security Note: Make sure to change the default DEFAULT_ADMIN_PASS value from password123 to a strong and secure password before deploying to production.

Example .env configuration:

#Rebranding
#APP_NAME=MdlWr
#APP_LOGO=/static/custom/icon.png
#APP_FAVICON=/static/custom/icon.ico
#LOGIN_BG_IMAGE=/static/custom/login_background.png

# Volume for docker compose
#MDLWR_BASE=D:/mdlwr
MDLWR_BASE=/opt/mdlwr

#Important: For SITE_TYPE=remote configurations, you must uncomment the 'ACTIVATE ONLY FOR SITE REMOTE MDLWR' section in your docker-compose.yml file for the services to function correctly.
SITE_TYPE=central
SITE_ID=00000000-0000-0000-0000-000000000000
SYNC_INTERVAL=300

# --- App Mode ---
FLASK_APP=run.py
FLASK_ENV=development
FLASK_DEBUG=0
DEBUG=DEBUG

# --- Security ---
SECRET_KEY=b572f2578c47cb1565afb748179a5831f3242e8b15bea78ff17698xxxxxxxxxx
SHOW_PASSWORD=False

# --- PostgreSQL Settings FOR DOCKER ---
DEFAULT_ADMIN_USER=admin
DEFAULT_ADMIN_EMAIL=admin@example.com
DEFAULT_ADMIN_PASS=password123

DB_HOST=db
DB_PORT=5432
DB_USERNAME=myuser
DB_PASS=mypass
DB_NAME=mydb

DATABASE_URL=postgresql://myuser:mypass@db:5432/mydb
REDIS_URL=redis://redis:6379

# --- Redis ---
REDIS_HOST=redis
REDIS_PORT=6379

# Logging
LOG_LEVEL=INFO
LOG_TO_REDIS=True
CONSOLE_DEBUG=False

#Integration flow
MAX_FLOW_DEPTH=10

4. Nginx Configuration File

The Nginx container requires a robust configuration file mapped from the host to handle SSL certificates, WebSocket routing, security headers, and reverse proxying to backend services.

Create or update the nginx.conf file inside /opt/mdlwr/nginx/nginx.conf with the following production-ready configuration:

events {
    worker_connections 1024;
}

http {
    # --- Konfigurasi Global & Headers ---
    include          /etc/nginx/mime.types;
    default_type     application/octet-stream;
    server_tokens    off;

    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-when-downgrade" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
    add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:; worker-src 'self' blob:; frame-ancestors 'self';" always;


    resolver 127.0.0.11 valid=10s;
    access_log /app/logs/nginx-access.log;
    error_log  /app/logs/nginx-error.log warn;

    # --- Maps & Rate Limiting ---
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    map $uri $limit_key {
        ~^/static/   "";
        default      $remote_addr;
    }

    limit_req_zone $remote_addr zone=api_limit:10m rate=20r/s;
    limit_req_status 429;

    # --- SERVER 1: Redirect HTTP ke HTTPS ---
    server {
        listen 80;
        server_name mdlwr.web.id;
        return 301 https://$host$request_uri;
    }

    # --- SERVER 2: HTTPS (Port 443) ---
    server {
        listen 443 ssl;
        server_name mdlwr.web.id;

        ssl_certificate /etc/letsencrypt/live/mdlwr.web.id/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/mdlwr.web.id/privkey.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;

        client_max_body_size 100M;

        location @handle_unauthorized {
            return 302 /login;
        }

        # Global Proxy Headers
        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;

        # --- SERVICES ---

        # --- DOCKER LOG STREAM (WEBSOCKET) ---
        location ~ ^/system/logs/stream/(.*) {
            set $target_terminal terminal;
            proxy_pass http://$target_terminal:5002/logs/stream/$1;

            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';

            proxy_read_timeout 86400s;
            proxy_send_timeout 86400s;

            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header Host $host;
        }

       # --- DOCKER TERMINAL EXEC (WEBSOCKET) ---
        location ~ ^/system/logs/exec/(.*) {
            set $target_terminal terminal;
            proxy_pass http://$target_terminal:5001/logs/exec/$1;

            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;

            proxy_read_timeout 86400s;
            proxy_send_timeout 86400s;

            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }

        location = /internal-auth-check {
            internal;
            proxy_pass http://app:5000/internal/auth;
            proxy_pass_request_body off;
            proxy_set_header Content-Length "";
            proxy_set_header Cookie $http_cookie;
            proxy_set_header Host $host;
            proxy_intercept_errors on;
        }

        location ^~ /static/exports/ {
            auth_request /internal-auth-check;
            error_page 401 403 = @handle_unauthorized;

            proxy_pass http://app:5000;
            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;

            expires 0;
            add_header Cache-Control "private, no-cache, no-store, must-revalidate";
        }

        location ^~ /static/ {
            proxy_pass http://app:5000;
            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;

            expires 7d;
            add_header Cache-Control "public, no-transform";
        }

        location / {
            proxy_pass http://app:5000;
            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;
        }
    }
}

5. Docker Compose File (docker-compose.yml)

Create a docker-compose.yml file in the /opt/mdlwr/ root directory to define all application stack services including Database, Redis, Web Application, Celery Workers, File Manager, and Nginx reverse proxy.

Example docker-compose.yml file:

x-logging: &default-logging
  logging:
    driver: "json-file"
    options:
      max-size: "10m"
      max-file: "3"

services:
  app:
    <<: *default-logging
    image: ghcr.io/ikhsanrasyidi/mdlwr:latest
    user: root
    command: gunicorn run:app -w 1 --threads 2 -b 0.0.0.0:5000 --timeout 120 --graceful-timeout 5
    stop_grace_period: 5s
    env_file:
      - .env
    environment:
      TZ: Asia/Jakarta
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ${MDLWR_BASE}/logs:/app/logs
      - ${MDLWR_BASE}/tmp/incoming:/app/tmp/incoming
      - ${MDLWR_BASE}/apps/static:/app/apps/static
      - ${MDLWR_BASE}/apps/static/custom:/app/apps/static/custom
      - /var/run/docker.sock:/var/run/docker.sock
    deploy:
      replicas: 1
    restart: always
    mem_limit: 600m
    cpus: "0.7"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5000/"]
      interval: 3s
      timeout: 2s
      retries: 5
      start_period: 3s

  terminal:
    <<: *default-logging
    container_name: terminal
    image: ghcr.io/ikhsanrasyidi/mdlwr-terminal:latest
    stop_grace_period: 3s
    env_file:
      - .env
    environment:
      TZ: Asia/Jakarta
    ports:
      - "5001:5001"
      - "5002:5002"
    volumes:
      - ${MDLWR_BASE}/logs:/app/logs
      - ${MDLWR_BASE}/tmp/incoming:/app/tmp/incoming
      - ${MDLWR_BASE}/apps/static:/app/static
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      app:
        condition: service_healthy
    restart: always

  nginx:
    <<: *default-logging
    container_name: nginx
    image: nginx:alpine
    command: ["nginx", "-g", "daemon off;", "-c", "/app/nginx/nginx.conf"]
    stop_grace_period: 2s
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ${MDLWR_BASE}/nginx/nginx.conf:/app/nginx/nginx.conf:ro
      - /etc/letsencrypt/live:/etc/letsencrypt/live:ro
      - /etc/letsencrypt/archive:/etc/letsencrypt/archive:ro
      - ${MDLWR_BASE}/apps/static:/app/apps/static:ro
      - ${MDLWR_BASE}/logs:/app/logs
    depends_on:
      app:
        condition: service_healthy
    restart: always
    mem_limit: 100m
    cpus: "0.2"

  job:
    <<: *default-logging
    image: ghcr.io/ikhsanrasyidi/mdlwr:latest
    command: celery -A apps.workers.job_worker worker --loglevel=info -Q job_queue --concurrency=1
    stop_grace_period: 3s
    env_file:
      - .env
    environment:
      TZ: Asia/Jakarta
      CELERY_BROKER_URL: redis://redis:6379/0
      CELERY_RESULT_BACKEND: redis://redis:6379/1
      C_FORCE_ROOT: "true"
    depends_on:
      app:
        condition: service_healthy
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ${MDLWR_BASE}/logs:/app/logs
      - ${MDLWR_BASE}/tmp/incoming:/app/tmp/incoming
    restart: always
    mem_limit: 300m
    cpus: "0.5"

  row:
    <<: *default-logging
    image: ghcr.io/ikhsanrasyidi/mdlwr:latest
    command: celery -A apps.workers.row_worker worker --loglevel=info -Q row_queue --concurrency=1
    stop_grace_period: 3s
    env_file:
      - .env
    environment:
      TZ: Asia/Jakarta
      CELERY_BROKER_URL: redis://redis:6379/0
      CELERY_RESULT_BACKEND: redis://redis:6379/1
      C_FORCE_ROOT: "true"
    depends_on:
      app:
        condition: service_healthy
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ${MDLWR_BASE}/logs:/app/logs
      - ${MDLWR_BASE}/tmp/incoming:/app/tmp/incoming
      - /var/run/docker.sock:/var/run/docker.sock
    restart: always
    mem_limit: 300m
    cpus: "0.5"

  email:
    <<: *default-logging
    image: ghcr.io/ikhsanrasyidi/mdlwr:latest
    command: celery -A apps.workers.email_worker worker -Q email_queue --concurrency=1
    stop_grace_period: 3s
    env_file:
      - .env
    environment:
      TZ: Asia/Jakarta
      CELERY_BROKER_URL: redis://redis:6379/0
      CELERY_RESULT_BACKEND: redis://redis:6379/1
      C_FORCE_ROOT: "true"
    depends_on:
      app:
        condition: service_healthy
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ${MDLWR_BASE}/logs:/app/logs
      - ${MDLWR_BASE}/tmp/incoming:/app/tmp/incoming
    restart: always

  scheduler:
    <<: *default-logging
    container_name: scheduler
    image: ghcr.io/ikhsanrasyidi/mdlwr:latest
    command: python -m apps.workers.run_scheduler
    stop_grace_period: 2s
    env_file:
      - .env
    environment:
      TZ: Asia/Jakarta
    depends_on:
      app:
        condition: service_healthy
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ${MDLWR_BASE}/logs:/app/logs
      - ${MDLWR_BASE}/tmp/incoming:/app/tmp/incoming
    restart: always
    mem_limit: 150m
    cpus: "0.3"

  db:
    <<: *default-logging
    container_name: db
    image: postgres:14
    restart: always
    stop_grace_period: 5s
    environment:
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypass
      POSTGRES_DB: mydb
    ports:
      - "5432:5432"
    volumes:
      - ${MDLWR_BASE}/volumes/postgres:/var/lib/postgresql/data
    command:
      - postgres
      - -c
      - shared_buffers=64MB
      - -c
      - work_mem=4MB
      - -c
      - maintenance_work_mem=32MB
      - -c
      - max_connections=50
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myuser -d mydb"]
      interval: 3s
      timeout: 2s
      retries: 5
      start_period: 3s
    mem_limit: 400m
    cpus: "0.5"

  redis:
    <<: *default-logging
    container_name: redis
    image: redis:7-alpine
    restart: always
    stop_grace_period: 3s
    volumes:
      - ${MDLWR_BASE}/volumes/redis:/data
    command:
      - redis-server
      - --bind
      - 0.0.0.0
      - --protected-mode
      - "no"
      - --save
      - ""
      - --appendonly
      - "no"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 3s
      timeout: 2s
      retries: 5
      start_period: 2s
    mem_limit: 150m
    cpus: "0.2"

  stream:
    <<: *default-logging
    image: ghcr.io/ikhsanrasyidi/mdlwr:latest
    command: python -m apps.workers.run_stream_worker
    stop_grace_period: 2s
    env_file:
      - .env
    environment:
      TZ: Asia/Jakarta
    depends_on:
      app:
        condition: service_healthy
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ${MDLWR_BASE}/logs:/app/logs
      - ${MDLWR_BASE}/tmp/incoming:/app/tmp/incoming
    restart: always
    mem_limit: 200m
    cpus: "0.4"

  # == ACTIVATE ONLY FOR SITE REMOTE MDLWR ==
  sync:
    <<: *default-logging
    container_name: sync
    image: ghcr.io/ikhsanrasyidi/mdlwr:latest
    command: python -m apps.workers.run_sync_worker
    stop_grace_period: 3s
    env_file:
      - .env
    environment:
      TZ: Asia/Jakarta
      C_FORCE_ROOT: "true"
    depends_on:
      app:
        condition: service_healthy
      db:
        condition: service_healthy
    volumes:
      - ${MDLWR_BASE}/logs:/app/logs
      - ${MDLWR_BASE}/tmp/incoming:/app/tmp/incoming
      - ${MDLWR_BASE}/apps/static:/app/static
      - ${MDLWR_BASE}/migrations:/app/migrations
    restart: always
    mem_limit: 400m
    cpus: "0.5"

6. Deploying the Stack

Once your docker-compose.yml, .env, and folder structures are in place, run the following command to download images, initialize the containers, and start all background services:

docker compose up -d --build

Automatic Initialization Notice

During the first startup, the Flask application container (app) will automatically:

  1. Initialize the database tables (db.create_all()).

  2. Run setup_default_admin() to securely seed the initial admin account using the credentials defined in your .env file (DEFAULT_ADMIN_USER, DEFAULT_ADMIN_EMAIL, DEFAULT_ADMIN_PASS).

  3. Set up PostgreSQL audit trigger functions, system identities, and synchronization registries.

Verifying the Installation

Once you run the docker-compose deployment command, verify that all core services and background workers start successfully. A healthy deployment will show 11/11 containers running as seen below:

Expected Running Services:

  • Infrastructure: redis, db (PostgreSQL), nginx
  • Core Application: mdlwr-app-1, terminal
  • Asynchronous Workers: scheduler, sync, mdlwr-job-1, mdlwr-row-1, mdlwr-email-1, mdlwr-stream-1

Verifying the Deployment

To ensure all containers are running smoothly without errors, check the logs or container status using:

docker compose ps
docker compose logs -f app

7. Operational Management Scripts (mdlwr.sh / mdlwr.bat)

To simplify daily application management and container orchestration, you can use management helper scripts. Place these scripts in the root deployment directory alongside your docker-compose.yml file.

Linux / Unix Environments (mdlwr.sh)

Create mdlwr.sh in /opt/mdlwr/mdlwr.sh to easily control container lifecycles and monitor deployment durations:

#!/usr/bin/env bash

# Default action to 'restart' if no argument is provided
ACTION="${1:-restart}"

echo "===================================="
echo "  MDLWR CLI: docker compose $ACTION"
echo "===================================="

if [ "$ACTION" = "start" ]; then
    shift 1
    time docker compose up -d "$@"
elif [ "$ACTION" = "stop" ]; then
    shift 1
    time docker compose down "$@"
elif [ "$ACTION" = "restart" ]; then
    time (docker compose down && docker compose up -d)
else
    time docker compose "$@"
fi

echo "===================================="

Make the script executable:

chmod +x /opt/mdlwr/mdlwr.sh

Usage Examples:

./mdlwr.sh          # Restarts the entire stack (default)
./mdlwr.sh start    # Starts containers in detached mode
./mdlwr.sh stop     # Stops and removes containers
./mdlwr.sh ps       # Passes custom commands directly to docker compose

Windows Environments (mdlwr.bat)

For Windows-based host environments, create mdlwr.bat inside your deployment root directory (e.g., D:\mdlwr\mdlwr.bat):

@echo off
setlocal enabledelayedexpansion

set "COMMAND=%~1"
if "%COMMAND%"=="" set "COMMAND=restart"

:: Command Aliases
if /i "%COMMAND%"=="start" set "COMMAND=up -d"
if /i "%COMMAND%"=="stop" set "COMMAND=down"

echo ====================================
echo    MDLWR CLI: docker compose %COMMAND%
echo ====================================

:: Capture Start Time
set "T_START=%time%"
call :GetSeconds "%T_START%" SEC_START

if /i "%COMMAND%"=="restart" (
    cmd /v:on /c "docker compose down"
    echo ------------------------------------
    cmd /v:on /c "docker compose up -d"
) else (
    cmd /v:on /c "docker compose %*"
)

:: Capture End Time
set "T_END=%time%"
call :GetSeconds "%T_END%" SEC_END

:: Calculate Execution Duration
set /a DURATION=%SEC_END% - %SEC_START%

:: Handle Midnight Crossover
if %DURATION% LSS 0 set /a DURATION+=86400

echo ------------------------------------
echo  Start           : %T_START%
echo  Finish          : %T_END%
echo  TOTAL Duration  : %DURATION% second(s)
echo ====================================
goto :eof

:: Helper Function: Convert %time% to Total Seconds
:GetSeconds
for /f "tokens=1-4 delims=:. " %%a in ("%~1") do (
    set /a "h=100%%a %% 100", "m=100%%b %% 100", "s=100%%c %% 100"
    set /a "%2=(h*3600) + (m*60) + s"
)
goto :eof

Usage Examples:

mdlwr.bat          :: Restarts the entire stack
mdlwr.bat start    :: Starts all containers
mdlwr.bat stop     :: Stops all containers
mdlwr.bat logs -f  :: Pass-through command to inspect logs