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
}
]
]
}
}
}
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
}
]
]
}
}
}
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
}
]
]
}
}
}
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
}
],
[]
]
}
}
}
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
}
]
]
}
}
}
Getting started
- Copy any workflow JSON above
- In n8n: Import workflow โ paste JSON
- Update credential references (Google Sheets, Postgres, Slack, Gmail) with your own
- Adjust sensor API endpoints, mine site IDs, and Sheets data ranges to match your schema
- 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)