DEV Community

Alex Kane
Alex Kane

Posted on

n8n for Mining & Resources SaaS: 5 Automations for MSHA Compliance, Tailings Safety, and Ops (Free Workflow JSON)

n8n for Mining & Resources SaaS: 5 Automations for MSHA Compliance, Tailings Safety, and Ops

Mining and resources companies operate at the intersection of extreme operational complexity and unforgiving regulatory requirements. An MSHA-reportable incident that isn't filed within 4 hours triggers penalties. A tailings storage facility (TSF) freeboard measurement that goes unmonitored can become a Category 5 disaster. EPA RCRA hazardous waste stored a day past the 90-day limit means $37,500/day in penalties.

The SaaS companies building software for mining operations โ€” production monitoring platforms, safety compliance tools, mine planning software, environmental management systems โ€” face the same challenge their customers do: too much data, too many deadlines, not enough automation.

n8n is a perfect fit for this vertical. Self-hosted on your own infrastructure (critical for mine site data sovereignty), it handles high-frequency sensor data, multi-step compliance workflows, and complex escalation logic that Zapier and Make simply can't replicate.

Here are 5 production-ready workflows with complete import-ready JSON.


Why n8n for Mining & Resources SaaS?

n8n (self-hosted) Zapier Make.com
Mine site sensor data egress Stays on-prem Routes through US cloud Routes through EU cloud
MSHA incident audit trail Git-versioned JSON No versioning No versioning
TSF real-time polling โœ… 15min intervals free ๐Ÿšซ Poll only, expensive โš ๏ธ Limited
RCRA compliance logic Full JS code nodes Basic filters only Basic filters
Deployment On-prem / air-gapped SaaS only SaaS only
Cost at 100K ops/mo ~$0 (self-hosted) ~$600/mo ~$180/mo

1. Mine Worker Safety Incident Alert & MSHA Reporting Pipeline

The problem: MSHA requires immediate notification for fatalities and serious injuries (within 15 minutes for fatalities, 4 hours for hospitalizations under 30 CFR ยง50.10). A manual process means missed deadlines and willful violation penalties.

This workflow:

  • Receives incident reports via webhook from your mobile safety app
  • Classifies severity: FATAL / MSHA_REPORTABLE / RECORDABLE / FIRST_AID / NEAR_MISS
  • For FATAL/MSHA_REPORTABLE: triggers immediate Slack alert to #safety-emergency and pre-fills MSHA 7000-1 electronic form
  • Logs all incidents to Postgres with full audit trail (30 CFR ยง50.30 recordkeeping requirement)
{
  "name": "Mine Worker Safety Incident Alert & MSHA Reporting Pipeline",
  "nodes": [
    {
      "id": "1",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "parameters": {
        "path": "mine-safety-incident",
        "responseMode": "onReceived",
        "httpMethod": "POST"
      },
      "position": [
        240,
        300
      ]
    },
    {
      "id": "2",
      "name": "Code: Classify Severity",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const inc = $input.first().json.body;\nconst sev_map = {FATAL:'FATAL', PERMANENT_DISABILITY:'FATAL', HOSPITALIZATION:'MSHA_REPORTABLE', LOST_TIME:'MSHA_REPORTABLE', RESTRICTED_DUTY:'RECORDABLE', MEDICAL_TREATMENT:'RECORDABLE', FIRST_AID:'FIRST_AID', NEAR_MISS:'NEAR_MISS'};\nconst sev = sev_map[inc.injury_type] || 'FIRST_AID';\nconst msha_deadline_h = sev === 'FATAL' ? 0.25 : sev === 'MSHA_REPORTABLE' ? 4 : null;\nconst msha_7000_required = ['FATAL','MSHA_REPORTABLE'].includes(sev);\nreturn [{json:{...inc, severity:sev, msha_deadline_h, msha_7000_required, ts: new Date().toISOString()}}];"
      },
      "position": [
        460,
        300
      ]
    },
    {
      "id": "3",
      "name": "IF: Requires MSHA Notification",
      "type": "n8n-nodes-base.if",
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json.msha_7000_required}}",
              "value2": true
            }
          ]
        }
      },
      "position": [
        680,
        300
      ]
    },
    {
      "id": "4",
      "name": "Slack: #safety-emergency",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#safety-emergency",
        "text": "=\ud83d\udea8 *{{$json.severity}} INCIDENT* at {{$json.mine_name}} ({{$json.section}})\nWorker: {{$json.worker_id}} | Injury: {{$json.injury_description}}\nMSHA 7000-1 required within {{$json.msha_deadline_h}}h\nSupervisor: {{$json.supervisor_name}} | Ref: {{$json.incident_id}}"
      },
      "position": [
        900,
        200
      ]
    },
    {
      "id": "5",
      "name": "HTTP: MSHA Electronic Form",
      "type": "n8n-nodes-base.httpRequest",
      "parameters": {
        "url": "https://www.msha.gov/api/accident-injury/submit",
        "method": "POST",
        "bodyParameters": {
          "parameters": [
            {
              "name": "mine_id",
              "value": "={{$json.mine_id}}"
            },
            {
              "name": "incident_date",
              "value": "={{$json.incident_date}}"
            },
            {
              "name": "injury_type",
              "value": "={{$json.injury_type}}"
            },
            {
              "name": "days_lost",
              "value": "={{$json.days_lost}}"
            }
          ]
        }
      },
      "position": [
        900,
        300
      ]
    },
    {
      "id": "6",
      "name": "Postgres: Audit Log",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "insert",
        "table": "safety_incidents",
        "columns": "incident_id,mine_id,worker_id,severity,injury_type,msha_7000_required,supervisor_name,ts",
        "values": "={{$json.incident_id}},={{$json.mine_id}},={{$json.worker_id}},={{$json.severity}},={{$json.injury_type}},={{$json.msha_7000_required}},={{$json.supervisor_name}},={{$json.ts}}"
      },
      "position": [
        900,
        400
      ]
    },
    {
      "id": "7",
      "name": "Slack: #safety-ops",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#safety-ops",
        "text": "=\u2139\ufe0f {{$json.severity}} incident logged (Ref: {{$json.incident_id}}) \u2014 {{$json.injury_description}} at {{$json.mine_name}}. No MSHA 7000-1 required."
      },
      "position": [
        900,
        500
      ]
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Code: Classify Severity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Classify Severity": {
      "main": [
        [
          {
            "node": "IF: Requires MSHA Notification",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF: Requires MSHA Notification": {
      "main": [
        [
          {
            "node": "Slack: #safety-emergency",
            "type": "main",
            "index": 0
          },
          {
            "node": "HTTP: MSHA Electronic Form",
            "type": "main",
            "index": 0
          },
          {
            "node": "Postgres: Audit Log",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: #safety-ops",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Tailings Storage Facility Monitoring Alert

The problem: After the Brumadinho and Mariana disasters, TSF monitoring has become a regulatory and existential priority. Freeboard levels, phreatic line ratios, and seepage rates need continuous monitoring with clear escalation protocols tied to MAC (Mining Association of Canada) Protocol.

This workflow:

  • Polls TSF sensor API every 15 minutes
  • Classifies risk: CRITICAL (freeboard <0.5m, slope failure risk) vs HIGH (freeboard <1.0m, phreatic line elevated, seepage +30%)
  • CRITICAL โ†’ Slack #tsf-emergency with MAC Protocol Level 2 review trigger
  • HIGH โ†’ Slack #tsf-monitoring for engineer review
{
  "name": "Tailings Storage Facility Monitoring Alert",
  "nodes": [
    {
      "id": "1",
      "name": "Schedule: Every 15min",
      "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "position": [
        240,
        300
      ]
    },
    {
      "id": "2",
      "name": "HTTP: TSF Sensor API",
      "type": "n8n-nodes-base.httpRequest",
      "parameters": {
        "url": "https://api.tsf-monitor.internal/v1/readings/latest",
        "method": "GET",
        "authentication": "headerAuth"
      },
      "position": [
        460,
        300
      ]
    },
    {
      "id": "3",
      "name": "Code: Classify TSF Risk",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const readings = $input.first().json.facilities;\nconst alerts = [];\nfor (const f of readings) {\n  let risk = 'OK';\n  const reasons = [];\n  if (f.freeboard_m < 0.5) { risk = 'CRITICAL'; reasons.push('FREEBOARD_CRITICAL <0.5m'); }\n  else if (f.freeboard_m < 1.0) { risk = 'HIGH'; reasons.push('FREEBOARD_LOW <1.0m'); }\n  if (f.phreatic_line_ratio > 0.85) { risk = risk === 'CRITICAL' ? 'CRITICAL' : 'HIGH'; reasons.push('PHREATIC_LINE_HIGH >85%'); }\n  if (f.seepage_rate_ls > f.design_seepage_ls * 1.3) { risk = 'HIGH'; reasons.push('SEEPAGE_ELEVATED +30%'); }\n  if (f.dam_slope_deg < f.min_slope_deg) { risk = 'CRITICAL'; reasons.push('SLOPE_FAILURE_RISK'); }\n  if (risk !== 'OK') alerts.push({...f, risk, reasons: reasons.join(', '), ts: new Date().toISOString()});\n}\nreturn alerts.map(a => ({json: a}));"
      },
      "position": [
        680,
        300
      ]
    },
    {
      "id": "4",
      "name": "IF: Critical vs High",
      "type": "n8n-nodes-base.if",
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{$json.risk}}",
              "operation": "equals",
              "value2": "CRITICAL"
            }
          ]
        }
      },
      "position": [
        900,
        300
      ]
    },
    {
      "id": "5",
      "name": "Slack: #tsf-emergency",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#tsf-emergency",
        "text": "=\ud83d\udea8 *TSF CRITICAL ALERT* \u2014 {{$json.facility_name}}\nRisk: {{$json.reasons}}\nFreeboard: {{$json.freeboard_m}}m | Phreatic ratio: {{$json.phreatic_line_ratio}}\nMAC Protocol Level 2 review required immediately\nContact: {{$json.tsf_engineer}}"
      },
      "position": [
        1100,
        200
      ]
    },
    {
      "id": "6",
      "name": "Slack: #tsf-monitoring",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#tsf-monitoring",
        "text": "=\u26a0\ufe0f *TSF HIGH Alert* \u2014 {{$json.facility_name}}: {{$json.reasons}}. Freeboard: {{$json.freeboard_m}}m. TSF engineer review required."
      },
      "position": [
        1100,
        400
      ]
    }
  ],
  "connections": {
    "Schedule: Every 15min": {
      "main": [
        [
          {
            "node": "HTTP: TSF Sensor API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP: TSF Sensor API": {
      "main": [
        [
          {
            "node": "Code: Classify TSF Risk",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Classify TSF Risk": {
      "main": [
        [
          {
            "node": "IF: Critical vs High",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF: Critical vs High": {
      "main": [
        [
          {
            "node": "Slack: #tsf-emergency",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: #tsf-monitoring",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

3. EPA RCRA Hazardous Waste Compliance Tracker

The problem: Large Quantity Generators (LQGs) must dispose of hazardous waste within 90 days. Small Quantity Generators (SQGs) get 270 days. A single day past the limit triggers ยง3008 penalties up to $37,500/day. Most mining EHS teams track this in a spreadsheet.

This workflow:

  • Runs daily at 7AM weekdays against your waste inventory spreadsheet
  • Calculates days accumulated vs storage limit (LQG=90d, SQG=270d) for each waste stream
  • Routes by severity: OVERDUE (immediate Slack #rcra-compliance + Gmail EHS) โ†’ CRITICAL (7 days left) โ†’ URGENT (14 days) โ†’ WARNING (30 days)
  • Includes LDR (Land Disposal Restrictions) treatment standard tracking and manifest reference
{
  "name": "EPA RCRA Hazardous Waste Compliance Tracker",
  "nodes": [
    {
      "id": "1",
      "name": "Schedule: Daily 7AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 7 * * 1-5"
            }
          ]
        }
      },
      "position": [
        240,
        300
      ]
    },
    {
      "id": "2",
      "name": "Google Sheets: Waste Inventory",
      "type": "n8n-nodes-base.googleSheets",
      "parameters": {
        "operation": "getAll",
        "sheetId": "SHEET_ID",
        "range": "WasteInventory!A:J"
      },
      "position": [
        460,
        300
      ]
    },
    {
      "id": "3",
      "name": "Code: Check RCRA Compliance",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const rows = $input.all().map(i => i.json);\nconst today = new Date();\nconst alerts = [];\nfor (const row of rows) {\n  if (!row.waste_code || row.status === 'DISPOSED') continue;\n  const accumulated = new Date(row.accumulation_start_date);\n  const days = Math.floor((today - accumulated) / 86400000);\n  const gen_type = row.generator_type; // LQG=90d, SQG=270d, VSQG=no_limit\n  const limit = gen_type === 'LQG' ? 90 : gen_type === 'SQG' ? 270 : 9999;\n  const days_remaining = limit - days;\n  let severity = 'OK';\n  if (days_remaining <= 0) severity = 'OVERDUE';\n  else if (days_remaining <= 7) severity = 'CRITICAL';\n  else if (days_remaining <= 14) severity = 'URGENT';\n  else if (days_remaining <= 30) severity = 'WARNING';\n  if (severity !== 'OK') {\n    alerts.push({...row, days_accumulated: days, days_remaining, severity, storage_limit_days: limit, ts: today.toISOString()});\n  }\n}\nreturn alerts.map(a => ({json: a}));"
      },
      "position": [
        680,
        300
      ]
    },
    {
      "id": "4",
      "name": "Switch: Severity Route",
      "type": "n8n-nodes-base.switch",
      "parameters": {
        "rules": {
          "rules": [
            {
              "value1": "={{$json.severity}}",
              "operation": "equals",
              "value2": "OVERDUE",
              "output": 0
            },
            {
              "value1": "={{$json.severity}}",
              "operation": "equals",
              "value2": "CRITICAL",
              "output": 1
            },
            {
              "value1": "={{$json.severity}}",
              "operation": "equals",
              "value2": "URGENT",
              "output": 2
            },
            {
              "value1": "={{$json.severity}}",
              "operation": "equals",
              "value2": "WARNING",
              "output": 3
            }
          ]
        }
      },
      "position": [
        900,
        300
      ]
    },
    {
      "id": "5",
      "name": "Slack: #rcra-compliance OVERDUE",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#rcra-compliance",
        "text": "=\ud83d\udea8 *EPA RCRA VIOLATION* \u2014 {{$json.waste_code}} at {{$json.storage_unit}}\nGenerator: {{$json.generator_type}} | Limit: {{$json.storage_limit_days}}d | Actual: {{$json.days_accumulated}}d\nLDR Treatment Standard: {{$json.ldr_standard}}\nEPA Contact required immediately \u2014 potential \u00a73008 penalties $37,500/day"
      },
      "position": [
        1100,
        100
      ]
    },
    {
      "id": "6",
      "name": "Slack: #rcra-compliance CRITICAL",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#rcra-compliance",
        "text": "=\u26a0\ufe0f *RCRA CRITICAL* \u2014 {{$json.waste_code}}: {{$json.days_remaining}} days remaining. Arrange disposal immediately."
      },
      "position": [
        1100,
        250
      ]
    },
    {
      "id": "7",
      "name": "Gmail: EHS Manager",
      "type": "n8n-nodes-base.gmail",
      "parameters": {
        "to": "={{$json.ehs_manager_email}}",
        "subject": "=RCRA {{$json.severity}}: {{$json.waste_code}} \u2014 {{$json.days_remaining}} days remaining",
        "message": "=Waste code {{$json.waste_code}} at {{$json.storage_unit}} has {{$json.days_remaining}} days remaining under {{$json.generator_type}} ({{$json.storage_limit_days}}-day rule).\n\nLDR Treatment Standard: {{$json.ldr_standard}}\nManifest No: {{$json.manifest_number}}\n\nAction required: arrange disposal before limit or request EPA extension."
      },
      "position": [
        1100,
        400
      ]
    },
    {
      "id": "8",
      "name": "Gmail: EHS Warning",
      "type": "n8n-nodes-base.gmail",
      "parameters": {
        "to": "={{$json.ehs_manager_email}}",
        "subject": "=RCRA WARNING: {{$json.waste_code}} \u2014 {{$json.days_remaining}} days remaining",
        "message": "=Early notice: {{$json.waste_code}} at {{$json.storage_unit}} has {{$json.days_remaining}} days before {{$json.generator_type}} storage limit."
      },
      "position": [
        1100,
        550
      ]
    }
  ],
  "connections": {
    "Schedule: Daily 7AM": {
      "main": [
        [
          {
            "node": "Google Sheets: Waste Inventory",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Sheets: Waste Inventory": {
      "main": [
        [
          {
            "node": "Code: Check RCRA Compliance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Check RCRA Compliance": {
      "main": [
        [
          {
            "node": "Switch: Severity Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Switch: Severity Route": {
      "main": [
        [
          {
            "node": "Slack: #rcra-compliance OVERDUE",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: #rcra-compliance CRITICAL",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Gmail: EHS Manager",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Gmail: EHS Warning",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Heavy Equipment Predictive Maintenance Scheduler

The problem: Unplanned downtime for a haul truck or dragline at a large open pit mine can cost $50,000โ€“$200,000/hour in lost production. Yet most mines still rely on calendar-based maintenance schedules rather than condition-based alerts.

This workflow:

  • Queries Postgres equipment health table daily at 6AM
  • Detects: hours approaching service limit (โ‰ฅ90%), oil temperature elevated (โ‰ฅ95% of max), vibration RMS elevated (โ‰ฅ90% of max), hydraulic pressure low
  • Classifies: OVERDUE (hours exceeded) / CRITICAL (98%+ or sensor anomaly) / DUE_SOON
  • Alerts maintenance supervisor via Slack #maintenance-urgent and direct Gmail with work order request
{
  "name": "Heavy Equipment Predictive Maintenance Scheduler",
  "nodes": [
    {
      "id": "1",
      "name": "Schedule: Daily 6AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 6 * * *"
            }
          ]
        }
      },
      "position": [
        240,
        300
      ]
    },
    {
      "id": "2",
      "name": "Postgres: Equipment Health",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT e.*, s.oil_temp_c, s.vibration_rms, s.hydraulic_pressure_bar, s.engine_hours_today, EXTRACT(EPOCH FROM (NOW() - e.last_service_date))/3600 AS hours_since_service FROM equipment e JOIN equipment_sensors s ON e.id = s.equipment_id WHERE e.status = 'ACTIVE' AND e.next_service_hours IS NOT NULL AND (e.total_hours >= e.next_service_hours * 0.90 OR s.oil_temp_c > e.max_oil_temp_c * 0.95 OR s.vibration_rms > e.max_vibration_rms * 0.90)"
      },
      "position": [
        460,
        300
      ]
    },
    {
      "id": "3",
      "name": "Code: Classify Maintenance Priority",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const eq = $input.first().json;\nlet priority = 'DUE_SOON';\nconst reasons = [];\nif (eq.total_hours >= eq.next_service_hours) { priority = 'OVERDUE'; reasons.push('SERVICE_HOURS_EXCEEDED'); }\nelse if (eq.total_hours >= eq.next_service_hours * 0.98) { priority = 'CRITICAL'; reasons.push('SERVICE_HOURS_98PCT'); }\nif (eq.oil_temp_c > eq.max_oil_temp_c * 0.95) { priority = 'CRITICAL'; reasons.push('OIL_TEMP_HIGH'); }\nif (eq.vibration_rms > eq.max_vibration_rms * 0.90) { priority = 'CRITICAL'; reasons.push('VIBRATION_ELEVATED'); }\nif (eq.hydraulic_pressure_bar < eq.min_hydraulic_pressure_bar * 1.05) { reasons.push('HYDRAULIC_PRESSURE_LOW'); }\nreturn [{json:{...eq, priority, reasons: reasons.join(', ')}}];"
      },
      "position": [
        680,
        300
      ]
    },
    {
      "id": "4",
      "name": "IF: Overdue or Critical",
      "type": "n8n-nodes-base.if",
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{$json.priority}}",
              "operation": "regex",
              "value2": "OVERDUE|CRITICAL"
            }
          ]
        }
      },
      "position": [
        900,
        300
      ]
    },
    {
      "id": "5",
      "name": "Slack: #maintenance-urgent",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#maintenance-urgent",
        "text": "=\ud83d\udd27 *{{$json.priority}} MAINTENANCE* \u2014 {{$json.equipment_name}} ({{$json.equipment_type}})\nSite: {{$json.mine_site}} | ID: {{$json.equipment_id}}\nReasons: {{$json.reasons}}\nTotal hours: {{$json.total_hours}} / Next service: {{$json.next_service_hours}}\nAssign work order \u2192 {{$json.maintenance_supervisor}}"
      },
      "position": [
        1100,
        200
      ]
    },
    {
      "id": "6",
      "name": "Gmail: Maintenance Supervisor",
      "type": "n8n-nodes-base.gmail",
      "parameters": {
        "to": "={{$json.supervisor_email}}",
        "subject": "={{$json.priority}}: {{$json.equipment_name}} maintenance required \u2014 {{$json.mine_site}}",
        "message": "=Equipment {{$json.equipment_name}} ({{$json.equipment_id}}) at {{$json.mine_site}} requires maintenance.\n\nPriority: {{$json.priority}}\nReason: {{$json.reasons}}\nCurrent hours: {{$json.total_hours}} | Service due at: {{$json.next_service_hours}} hours\n\nPlease schedule downtime and assign technician."
      },
      "position": [
        1100,
        400
      ]
    }
  ],
  "connections": {
    "Schedule: Daily 6AM": {
      "main": [
        [
          {
            "node": "Postgres: Equipment Health",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Postgres: Equipment Health": {
      "main": [
        [
          {
            "node": "Code: Classify Maintenance Priority",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Classify Maintenance Priority": {
      "main": [
        [
          {
            "node": "IF: Overdue or Critical",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF: Overdue or Critical": {
      "main": [
        [
          {
            "node": "Slack: #maintenance-urgent",
            "type": "main",
            "index": 0
          },
          {
            "node": "Gmail: Maintenance Supervisor",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Weekly Mining Operations & Safety KPI Dashboard

The problem: VP Operations and EHS managers need a single weekly view of production (ore mined, strip ratio, cost/tonne) alongside safety metrics (LTIFR, near misses, training compliance). Assembling this manually from multiple systems takes hours every Monday.

This workflow:

  • Runs every Monday at 7AM
  • Pulls production metrics from Postgres (ore tonnes, waste moved, strip ratio, revenue, cost/tonne)
  • Pulls safety metrics (LTI incidents, near misses, first aid, LTIFR per 1M hours, training compliance %)
  • Merges and builds a color-coded HTML email report
  • Sends to VP Operations with CC to EHS team
{
  "name": "Weekly Mining Operations & Safety KPI Dashboard",
  "nodes": [
    {
      "id": "1",
      "name": "Schedule: Monday 7AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 7 * * 1"
            }
          ]
        }
      },
      "position": [
        240,
        300
      ]
    },
    {
      "id": "2",
      "name": "Postgres: Production Metrics",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT SUM(ore_tonnes_mined) AS total_ore_t, SUM(waste_tonnes_moved) AS total_waste_t, ROUND(SUM(ore_tonnes_mined)::numeric/NULLIF(SUM(waste_tonnes_moved),0),2) AS strip_ratio, SUM(revenue_usd) AS revenue_usd, ROUND(SUM(cost_usd)/NULLIF(SUM(ore_tonnes_mined),0),2) AS cost_per_tonne FROM production_daily WHERE week_ending = DATE_TRUNC('week',NOW()) - INTERVAL '1 day'"
      },
      "position": [
        460,
        200
      ]
    },
    {
      "id": "3",
      "name": "Postgres: Safety Metrics",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT COUNT(*) FILTER (WHERE injury_type IN ('LOST_TIME','HOSPITALIZATION','FATAL')) AS ltifr_incidents, COUNT(*) FILTER (WHERE injury_type = 'NEAR_MISS') AS near_misses, COUNT(*) FILTER (WHERE injury_type = 'FIRST_AID') AS first_aid, ROUND(COUNT(*) FILTER (WHERE training_completed)::numeric / NULLIF(COUNT(*),0) * 100,1) AS training_compliance_pct FROM safety_weekly WHERE week_ending = DATE_TRUNC('week',NOW()) - INTERVAL '1 day'"
      },
      "position": [
        460,
        400
      ]
    },
    {
      "id": "4",
      "name": "Merge",
      "type": "n8n-nodes-base.merge",
      "parameters": {
        "mode": "multiplex"
      },
      "position": [
        680,
        300
      ]
    },
    {
      "id": "5",
      "name": "Code: Build HTML Report",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const [prod, safety] = [$input.all()[0].json, $input.all()[1].json];\nconst ltifr = (safety.ltifr_incidents / Math.max(prod.total_ore_t,1) * 1000000).toFixed(2);\nconst safetyColor = safety.ltifr_incidents > 0 ? '#dc3545' : '#28a745';\nconst html = `<h2 style='font-family:sans-serif'>Weekly Mining Operations Report</h2>\n<h3>Production</h3><table border='1' cellpadding='6'>\n<tr><td>Ore Mined</td><td>${prod.total_ore_t?.toLocaleString()} t</td></tr>\n<tr><td>Waste Moved</td><td>${prod.total_waste_t?.toLocaleString()} t</td></tr>\n<tr><td>Strip Ratio</td><td>${prod.strip_ratio}</td></tr>\n<tr><td>Revenue</td><td>$${prod.revenue_usd?.toLocaleString()}</td></tr>\n<tr><td>Cost/Tonne</td><td>$${prod.cost_per_tonne}</td></tr></table>\n<h3>Safety</h3><table border='1' cellpadding='6'>\n<tr><td>LTI Incidents</td><td style='color:${safetyColor}'>${safety.ltifr_incidents}</td></tr>\n<tr><td>Near Misses</td><td>${safety.near_misses}</td></tr>\n<tr><td>First Aid</td><td>${safety.first_aid}</td></tr>\n<tr><td>LTIFR</td><td>${ltifr} per 1M hours</td></tr>\n<tr><td>Training Compliance</td><td>${safety.training_compliance_pct}%</td></tr></table>`;\nreturn [{json:{html, prod, safety, ltifr}}];"
      },
      "position": [
        900,
        300
      ]
    },
    {
      "id": "6",
      "name": "Gmail: VP Operations",
      "type": "n8n-nodes-base.gmail",
      "parameters": {
        "to": "vp-ops@minecompany.com",
        "subject": "=Weekly Mining Report \u2014 W/E {{$now.format('MMM D, YYYY')}}",
        "message": "={{$json.html}}",
        "additionalFields": {
          "cc": "ehs@minecompany.com",
          "contentType": "html"
        }
      },
      "position": [
        1100,
        300
      ]
    }
  ],
  "connections": {
    "Schedule: Monday 7AM": {
      "main": [
        [
          {
            "node": "Postgres: Production Metrics",
            "type": "main",
            "index": 0
          },
          {
            "node": "Postgres: Safety Metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Postgres: Production Metrics": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Postgres: Safety Metrics": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Merge": {
      "main": [
        [
          {
            "node": "Code: Build HTML Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Build HTML Report": {
      "main": [
        [
          {
            "node": "Gmail: VP Operations",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Getting started

  1. Copy any workflow JSON above
  2. In n8n: Import workflow โ†’ paste JSON
  3. Update credential references (Google Sheets, Postgres, Slack, Gmail) with your own
  4. Adjust sensor API endpoints, mine site IDs, and Sheets data ranges to match your schema
  5. Activate

All 5 workflows are in the FlowKit n8n Template Store โ€” along with 15 other production-ready templates for SaaS ops, compliance, and customer success.


The self-hosted advantage for mining SaaS

Mine site data has unique constraints that make cloud iPaaS a compliance risk:

  • MSHA incident data โ€” 30 CFR ยง50.30 requires detailed recordkeeping; routing raw incident data through Zapier's US servers adds a data processor to your chain
  • TSF sensor readings โ€” tailings facility operational data may be subject to environmental law disclosure restrictions
  • Equipment telemetry โ€” OEM licensing agreements sometimes restrict third-party cloud routing of diagnostic data
  • Air-gapped mine sites โ€” remote operations often have no reliable internet; n8n runs on-prem on a local server or Raspberry Pi with periodic sync

n8n running on your own infrastructure means: sensor data never leaves your network, full git-versioned audit trail for regulatory review, and zero per-execution cost regardless of polling frequency.


Questions about adapting these workflows? Leave a comment below. The complete template library is at stripeai.gumroad.com.

Top comments (0)