Skip to content

Infrastructure & Deployment Plan — Laundry Management System

Document Information

Field Value
Project Laundry Management System
Version 1.0
Language English
Tech Stack ASP.NET Core, Angular, Tauri, PostgreSQL, Docker, Traefik, Seq
Document Type Infrastructure & Deployment Guide

Table of Contents

  1. Deployment Architecture Overview
  2. The Central docker-compose.yml
  3. Hardware Specifications
  4. Deployment Type A — Online Server (Type 1)
  5. Deployment Type B — Offline Branch (Type 2)
  6. Deployment Type C — Offline Branch with Sync (Type 3)
  7. Traefik Configuration — HTTPS for All Deployments
  8. Browser Access Prevention (Offline Branches)
  9. The Installation Wizard
  10. Branch Setup USB Package
  11. Database Backup Strategy
  12. Monitoring with Seq
  13. Rollback Procedure
  14. Environment Strategy
  15. Peripheral Setup

1. Deployment Architecture Overview

Every instance of the system — online server, offline branch, or offline-sync branch — runs the same docker-compose.yml stack. Behavior is controlled by a single .env file.

flowchart TB
    subgraph Docker["Docker Compose Stack"]
        Traefik["Traefik<br/>:80 :443 :8080<br/>Reverse Proxy<br/>SSL via Let's Encrypt / mkcert"]
        API["ASP.NET Core API<br/>:5000<br/>Serves /api + Angular static files"]
        PG["PostgreSQL 16<br/>:5432 (internal)<br/>Encrypted volume"]
        Seq["Seq<br/>:5341<br/>Logging & Tracing"]
    end

    Internet["Internet / Tauri App"] -->|HTTPS| Traefik
    Traefik -->|Route: /api/*| API
    Traefik -->|Route: /logs| Seq
    Traefik -->|Route: static| API
    API -->|SQL| PG
    API -->|Logs & Traces| Seq

    ENV[".env file<br/>BRANCH_MODE<br/>DOMAIN<br/>CERT_MODE<br/>BRANCH_ID"] -.- Docker

    subgraph Types["Deployment Types"]
        T1["Type A: Online Server<br/>CERT_MODE=acme<br/>DOMAIN=customer.com"]
        T2["Type B: Offline Branch<br/>CERT_MODE=static<br/>DOMAIN=laundry.local"]
        T3["Type C: Offline-Sync<br/>CERT_MODE=static<br/>DOMAIN=laundry.local<br/>+ Sync Export/Import"]
    end

    ENV ==> Types

Three Deployment Types

Type .env BRANCH_MODE Domain Certificates Internet Client
A — Online Server online Customer's real domain Let's Encrypt (ACME) Required Tauri app or browser
B — Offline Branch offline laundry.local mkcert (pre-generated) None Tauri app only
C — Offline-Sync Branch offline_sync laundry.local mkcert (pre-generated) None (except manual sync) Tauri app only

2. The Central docker-compose.yml

version: "3.9"

services:
  traefik:
    image: traefik:v3
    container_name: laundry-traefik
    restart: unless-stopped
    command:
      - "--api.dashboard=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      - "--certificatesresolvers.le.acme.email=${ACME_EMAIL:-admin@localhost}"
      - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
    ports:
      - "80:80"
      - "443:443"
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
      - ./certs:/certs:ro
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dashboard.rule=Host(`traefik.${DOMAIN}`)"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.entrypoints=websecure"

  postgres:
    image: postgres:16-alpine
    container_name: laundry-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: laundry
      POSTGRES_USER: laundry
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./backups:/backups

  api:
    image: ghcr.io/${GITHUB_ORG}/laundry-api:${VERSION:-latest}
    container_name: laundry-api
    restart: unless-stopped
    depends_on:
      - postgres
    environment:
      ConnectionStrings__Default: "Host=postgres;Port=5432;Database=laundry;Username=laundry;Password=${DB_PASSWORD}"
      BranchMode: ${BRANCH_MODE}
      BranchId: ${BRANCH_ID}
      BranchCode: ${BRANCH_CODE}
      Domain: ${DOMAIN}
      SeqServerUrl: "http://seq:5341"
      LicensePath: "/license/license.dat"
      ClientTokenSecret: ${CLIENT_TOKEN_SECRET}
      ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT:-Production}
    volumes:
      - ./license:/license:ro
      - ./backups:/backups
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.api.entrypoints=websecure"
      - "traefik.http.routers.api.tls.certresolver=le"
      - "traefik.http.services.api.loadbalancer.server.port=5000"

  seq:
    image: datalust/seq:2024.1
    container_name: laundry-seq
    restart: unless-stopped
    environment:
      ACCEPT_EULA: "Y"
    volumes:
      - seqdata:/data
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.seq.rule=Host(`logs.${DOMAIN}`)"
      - "traefik.http.routers.seq.entrypoints=websecure"

volumes:
  pgdata:
  seqdata:
  letsencrypt:

3. Hardware Specifications

Type A — Online Server (Customer VPS)

Resource Minimum Recommended
CPU 2 vCPU 4 vCPU
RAM 4 GB 8 GB
Disk 40 GB SSD 80 GB SSD
OS Ubuntu 24.04 LTS Ubuntu 24.04 LTS
Bandwidth 1 TB/month 2 TB/month
Providers Hetzner CX22 (~$8/mo), DigitalOcean Basic ($24/mo), AWS Lightsail ($20/mo)

Types B & C — Offline Branch PC

Resource Minimum Recommended
CPU Intel Core i3 / AMD Ryzen 3 Intel Core i5 / AMD Ryzen 5
RAM 8 GB 16 GB
Disk 256 GB SSD 512 GB SSD
OS Windows 10/11 Pro, Ubuntu 24.04 LTS, macOS 13+
GPU Integrated Integrated
Network None required (B/C); brief LAN/internet optional (C for sync)
UPS Recommended for power backup

4. Deployment Type A — Online Server (Type 1)

4.1 Server Preparation

# 1. Provision a fresh Ubuntu 24.04 VPS.
# 2. SSH into the server.
ssh root@<server-ip>

# 3. Install Docker
curl -fsSL https://get.docker.com | sh
usermod -aG docker $USER

# 4. Point DNS A record for the domain to the server IP:
#    laundry.customer-domain.com → <server-ip>
#    logs.customer-domain.com    → <server-ip>

# 5. Create the application directory structure.
mkdir -p /opt/laundry/{certs,license,backups}

4.2 .env File — Type A

# /opt/laundry/.env
BRANCH_MODE=online
BRANCH_ID=00000000-0000-0000-0000-000000000000
BRANCH_CODE=MAIN
DB_PASSWORD=<auto-generated-32-char>
DOMAIN=laundry.customer-domain.com
CERT_MODE=acme
ACME_EMAIL=admin@customer-domain.com
VERSION=1.0.0
GITHUB_ORG=your-org
CLIENT_TOKEN_SECRET=<auto-generated-64-char>
ASPNETCORE_ENVIRONMENT=Production

4.3 Startup

cd /opt/laundry

# Place license.dat in ./license/
# Run:
docker compose up -d

# Check all services:
docker compose ps
# Expected: traefik:Up, postgres:Up, api:Up, seq:Up

4.4 Update (Online)

cd /opt/laundry
# Update VERSION in .env, or pull latest:
docker compose pull
docker compose up -d

5. Deployment Type B — Offline Branch (Type 2)

5.1 Overview

Offline branches run an identical Docker Compose stack on a local PC. All traffic stays on localhost. HTTPS is provided by locally-trusted mkcert certificates. The Tauri desktop app connects to https://laundry.local.

5.2 Prerequisites (Installed via Branch Setup USB)

Component How Installed
Docker Engine Docker Desktop for Windows/macOS, or get.docker.com for Linux
mkcert Pre-downloaded binary on USB
Docker images (tar) docker load < laundry-images.tar
docker-compose.yml Copied from USB
Tauri installer Run .msi / .dmg / .deb from USB
license.dat Copied to C:\Laundry\license\
Setup script setup.bat / setup.sh

5.3 .env File — Type B

# C:\Laundry\.env
BRANCH_MODE=offline
BRANCH_ID=<branch-uuid>
BRANCH_CODE=CAIRO-01
DB_PASSWORD=<auto-generated-32-char>
DOMAIN=laundry.local
CERT_MODE=static
VERSION=1.0.0
GITHUB_ORG=your-org
CLIENT_TOKEN_SECRET=<auto-generated-64-char>
ASPNETCORE_ENVIRONMENT=Production

5.4 mkcert Setup (Run Once)

# On the branch PC, via setup script:
mkcert -install
mkcert laundry.local localhost 127.0.0.1 ::1

# Output: laundry.local+3.pem, laundry.local+3-key.pem
# Copy to C:\Laundry\certs\

5.5 Traefik Static Cert Config

A traefik.yml file mounted alongside certificates for offline mode:

# C:\Laundry\traefik\traefik.yml (only for offline)
tls:
  certificates:
    - certFile: /certs/laundry.local+3.pem
      keyFile: /certs/laundry.local+3-key.pem

5.6 Tauri Desktop App

After installation, the user double-clicks the "Laundry System" icon. The Tauri app opens a native window pointing to https://laundry.local. No browser ever appears.

5.7 Windows Auto-Start

To ensure Docker and the Laundry system start on boot:

# Create a scheduled task that runs on system startup:
# Action: Start a program
# Program: C:\Program Files\Docker\Docker\Docker Desktop.exe
# Then: docker compose -f C:\Laundry\docker-compose.yml up -d

6. Deployment Type C — Offline Branch with Sync (Type 3)

Identical to Type B in setup, with these additions:

6.1 .env Difference

BRANCH_MODE=offline_sync

6.2 Additional Features

Feature Implementation
Export Sync Data Admin clicks "Export Sync Data" in the Tauri app → system compresses unsynced PostgreSQL rows to an AES-256 encrypted .lndsync file → saved to USB
Import Sync Data (Central Side) Admin logs into the online server → Admin panel → "Import Branch Data" → uploads .lndsync → validation → upsert merge
Import Master Updates (Branch Side) Admin receives .lndmaster file (new price lists, services, etc.) → imports via Tauri app → updates local config
Health Report Included automatically in every .lndsync export: Docker status, disk usage, error counts, sync timestamp

6.3 Sync File Format

sync_cairo-01_2026-05-09.lndsync  (AES-256 encrypted ZIP containing:
  ├── journal_entry_lines.json
  ├── journal_entries.json
  ├── invoices.json
  ├── invoice_items.json
  ├── payments.json
  ├── customers.json
  ├── ... (all tables with sync_status='pending')
  └── health_report.json
)

# Encryption key derived from: SHA-256(license_key + branch_id)

7. Traefik Configuration — HTTPS for All Deployments

7.1 Online: Let's Encrypt (ACME)

Controlled entirely by environment variables. Traefik auto-provisions and renews certificates. The docker-compose.yml uses tls.certresolver=le on the api service label.

7.2 Offline: Static mkcert Certificates

For offline deployments, the Traefik container uses a config file mounted at /etc/traefik/traefik.yml that references the static .pem files:

# This file is created by the setup wizard based on CERT_MODE=static
providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

# No ACME resolver in offline mode.

tls:
  certificates:
    - certFile: /certs/laundry.local+3.pem
      keyFile: /certs/laundry.local+3-key.pem

The wizard generates this file during setup for Types B & C.


8. Browser Access Prevention (Offline Branches)

Users must use the Tauri desktop app, not a browser. Two-layer gate:

Layer 1: ASP.NET Core Middleware

public class ClientTokenMiddleware
{
    public async Task InvokeAsync(HttpContext context)
    {
        var mode = _config["BranchMode"];

        // Online deployments: no restriction
        if (mode == "online")
        {
            await _next(context);
            return;
        }

        // Offline: require X-Laundry-Client-Token header
        if (!context.Request.Headers.TryGetValue("X-Laundry-Client-Token", out var token)
            || !ValidateToken(token))
        {
            context.Response.StatusCode = 403;
            context.Response.ContentType = "text/html";
            await context.Response.WriteAsync(@"
                <html><body style='font-family:sans-serif;text-align:center;padding-top:80px'>
                <h1>Access Denied</h1>
                <p>Please use the <strong>Laundry System</strong> desktop application.</p>
                <p>Opening in a browser is not supported on this device.</p>
                </body></html>");
            return;
        }

        await _next(context);
    }

    private bool ValidateToken(string token)
    {
        // Token = HMAC-SHA256(license_key + branch_id, CLIENT_TOKEN_SECRET)
        var expected = ComputeHmac(_licenseKey + _branchId, _clientTokenSecret);
        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(token),
            Encoding.UTF8.GetBytes(expected));
    }
}

Layer 2: Tauri Injects the Token

In src-tauri/src/main.rs:

// On app startup, compute the client token from license + branch_id
// Store in global state
// Before the WebView loads, install a request interceptor that adds the header:
//   X-Laundry-Client-Token: <computed-token>

The token is computed from the license.dat file and laundry.config.json — known only to the Tauri binary. Angular never sees it.


9. The Installation Wizard

9.1 Overview

The wizard is built into the Tauri application. On first launch, if laundry.config.json is missing or setupComplete is false, the wizard screens appear instead of the login page.

9.2 Screens

Screen 1: Welcome & Type Selection - Choose: Online Server / Offline Branch / Offline-Sync Branch - Language toggle: English / العربية

Screen 2: Branch Information - Branch Name, Branch Code, Currency, Fiscal Year Start, Default Language - System auto-generates Branch UUID

Screen 3: License - File picker for license.dat - Auto-validates: extracts customer name, expiry date, max branches - Shows validation result with green check / red error

Screen 4: Domain (Online only) - Customer enters their domain name - System checks DNS resolution - Email for Let's Encrypt notifications

Screen 5: Docker Check & Service Start - Verifies Docker is installed and running - (First run) Loads images from laundry-images.tar - Creates Docker volumes - Generates .env file with all variables - Creates traefik.yml for offline (if applicable) - Generates mkcert certificates (if offline) - Starts docker compose up -d - Runs EF Core migrations (dotnet ef database update) - Pre-upgrade: creates a PostgreSQL dump backup

Screen 6: Admin Account - Username: default admin - Option 1: Click "Generate" for a 16-char auto-generated password - Option 2: Type custom password (min 8 chars, 1 uppercase, 1 number, 1 special char) - "I have saved this password" checkbox required before Finish - Show Password + Copy buttons available

Screen 7: Complete - Summary of what was set up - URL: https://laundry.local (offline) or https://customer-domain.com (online) - "Launch Laundry System" button

9.3 What the Wizard Produces

File Purpose
.env All Docker environment variables
laundry.config.json Tauri app config (apiUrl, mode, branchId, clientToken)
./certs/*.pem mkcert certificates (offline only)
./license/license.dat Customer license file
./traefik/traefik.yml Traefik static config (offline only)
Docker volumes created pgdata, seqdata, certs, license, backups
First admin user Created in the database

10. Branch Setup USB Package

For offline branch deployment by a technician, a single USB drive contains:

Laundry_Setup_USB/
├── README.txt                             # Instructions in AR/EN
├── setup.bat                              # Windows: runs everything
├── setup.sh                               # Linux/macOS: runs everything
├── docker/
│   ├── Docker Desktop Installer.exe       # Windows Docker
│   ├── docker-install.sh                  # Linux Docker install script
│   └── laundry-images_v1.0.0.tar          # docker save of all images
├── mkcert/
│   ├── mkcert-v1.4.4-windows-amd64.exe
│   ├── mkcert-v1.4.4-linux-amd64
│   └── mkcert-v1.4.4-darwin-amd64
├── app/
│   ├── LaundrySystem_1.0.0_x64_en-US.msi  # Tauri Windows installer
│   ├── LaundrySystem_1.0.0_aarch64.dmg    # Tauri macOS installer
│   └── laundry-system_1.0.0_amd64.deb     # Tauri Linux installer
├── docker-compose.yml
├── license.dat                            # Customer-specific
└── backup/                                # Empty, for post-setup backup

setup.bat (Windows — Simplified)

@echo off
echo ==========================================
echo   Laundry System - Branch Setup
echo ==========================================
echo.

echo [1/6] Installing Docker Engine...
start /wait "" "%~dp0docker\Docker Desktop Installer.exe" --quiet
echo Done.

echo [2/6] Loading Docker images...
docker load -i "%~dp0docker\laundry-images_v1.0.0.tar"
echo Done.

echo [3/6] Installing mkcert...
copy "%~dp0mkcert\mkcert-v1.4.4-windows-amd64.exe" "%ProgramFiles%\mkcert\mkcert.exe"
set PATH=%PATH%;%ProgramFiles%\mkcert
mkcert -install
mkcert laundry.local localhost 127.0.0.1 ::1
mkdir "C:\Laundry\certs"
move *.pem "C:\Laundry\certs\"
echo Done.

echo [4/6] Copying application files...
mkdir "C:\Laundry\license"
copy "%~dp0docker-compose.yml" "C:\Laundry\"
copy "%~dp0license.dat" "C:\Laundry\license\"
echo Done.

echo [5/6] Installing Laundry System desktop app...
msiexec /i "%~dp0app\LaundrySystem_1.0.0_x64_en-US.msi" /quiet
echo Done.

echo [6/6] Setup complete!
echo.
echo The Laundry System will start on next login.
echo Or run: docker compose -f C:\Laundry\docker-compose.yml up -d
echo Then launch "Laundry System" from the Start Menu.
pause

11. Database Backup Strategy

11.1 Central Server (Type 1)

Method Frequency Retention
pg_dump via cron Daily at 02:00 30 days
Docker volume snapshot Weekly 4 weeks
WAL archiving Continuous Enabled for point-in-time recovery
Off-site copy Daily Synced to S3/cloud storage

11.2 Offline Branches (Type 2 & 3)

Method How
Scheduled backup Quartz.NET job runs at 02:00 daily → docker exec laundry-postgres pg_dump ... → saves AES-256 encrypted .sql.gz to ./backups/ Docker volume (mounted to C:\Laundry\backups\ on host)
Pre-upgrade backup Wizard runs a full dump before applying EF Core migrations
Pre-sync backup (Type 3) Before exporting sync data, the system auto-creates a backup of the current state
Manual backup Admin UI button: "Create Backup" → saves to configured backup path
Backup rotation Last 7 daily backups retained. Weekly backups retained for 4 weeks.

11.3 Restore Procedure

# 1. Stop the application
docker compose -f C:\Laundry\docker-compose.yml stop api

# 2. Restore the database
docker exec -i laundry-postgres psql -U laundry -d laundry < backup_2026-05-09.sql

# 3. Restart
docker compose -f C:\Laundry\docker-compose.yml up -d

12. Monitoring with Seq

12.1 Architecture

Seq runs as a Docker container on every instance — online and offline. It receives structured logs from ASP.NET Core via the Serilog Seq sink. No external service required.

12.2 Accessing Seq

Deployment Access URL
Online (Type 1) https://logs.customer-domain.com (secured by Traefik + Let's Encrypt)
Offline (Types 2/3) http://localhost:5341 (local network only)

Alternatively, the Admin UI has a "System Logs" page that queries the Seq API and displays recent logs in-app. This avoids opening a separate tab on offline branches.

12.3 What Is Logged

  • All API requests (method, path, status code, duration)
  • All errors (full stack traces)
  • All accounting events (journal entry posted, GL balance mismatch)
  • All sync operations (export, import, validation errors)
  • All authentication events (login success/failure)
  • Docker health checks (service restarts)
  • License validation events

12.4 Remote Support

When a customer calls with an issue at an offline branch: 1. Remote into the branch PC via TeamViewer/AnyDesk. 2. Open http://localhost:5341. 3. Search for errors by time range, log level, or keyword. 4. Export relevant logs as JSON for offline analysis.


13. Rollback Procedure

13.1 Strategy

Every Docker image is tagged with its SemVer version. The previous version's image remains on the host until explicitly removed. The .env file controls which version is active.

13.2 Online Server Rollback

cd /opt/laundry

# 1. Stop current version
docker compose down

# 2. Edit .env: VERSION=1.0.1  (previous version)
nano .env

# 3. Start previous version
docker compose up -d

# 4. Verify
docker compose ps
docker compose logs api --tail=20

13.3 Offline Branch Rollback

cd C:\Laundry

# 1. Stop current version
docker compose down

# 2. Load previous image version (if not cached locally)
docker load -i laundry-images_v1.0.1.tar

# 3. Edit .env: VERSION=1.0.1
notepad .env

# 4. Start previous version
docker compose up -d

13.4 Database Compatibility

EF Core migrations are additive-only. Rolling back the API does not roll back the database — the previous API must be compatible with the newer schema. All migrations are designed to be backward-compatible:

  • Add columns: always nullable or with defaults
  • Remove columns: mark as obsolete, remove in next major version
  • Rename: never rename; add new, deprecate old

14. Environment Strategy

Environment Purpose Hosting Deployment
Dev (Local) Developer machines Docker Desktop Manual via docker compose up
Staging Pre-release testing, customer demos Small VPS ($10/mo) Auto via GitHub Actions on merge to develop
Production Customer servers Per customer (VPS or branch PC) Manual (online: docker compose pull; offline: USB)

15. Peripheral Setup

15.1 Thermal Printer (80mm)

OS Setup
Windows Install printer driver. Set paper size to 80mm × 297mm (or continuous roll). Name the printer "LaundryReceipt".
Linux CUPS setup with 80mm paper size.
macOS Standard printer setup.

The Angular app prints receipts via window.print() with @media print CSS sized for 80mm. The Tauri app provides an API to trigger system print.

15.2 Barcode Scanner (USB)

  • Any USB barcode scanner set to keyboard-emulation mode.
  • Scans into the active input field.
  • Scans Codes 128 barcodes generated by the system (via jsbarcode library).
  • Camera-based scanning: optional QR code scanning via the Tauri camera API for mobile/tablet use.

15.3 Cash Drawer (Optional)

If a cash drawer is connected: - Triggers open via ESC/POS command sent through the thermal printer or a dedicated serial command via Tauri's serial port API.


Revision History

Date Version Author Changes
2026-05-10 1.0 System Architect Initial infrastructure & deployment plan