{
  "id": "IL42Qidg9v8mBdy0",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Monitor SSL certificate expiry with Google Sheets and email alerts",
  "tags": [
    {
      "id": "cARfO2kXsq6Jij8F",
      "name": "n8n_creator",
      "createdAt": "2026-01-07T18:37:06.835Z",
      "updatedAt": "2026-01-07T18:37:06.835Z"
    }
  ],
  "nodes": [
    {
      "id": "361b9ff6-c10c-4621-bfa1-c587b868f8f0",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -4352,
        464
      ],
      "parameters": {
        "width": 380,
        "height": 924,
        "content": "## \ud83d\udd12 Monitor SSL certificate expiry\n\n**Who is this for?**\nDevOps engineers, sysadmins, and website owners who need to track SSL certificate expiration across multiple domains.\n\n**What it does:**\nAutomatically checks SSL certificates for a list of domains, tracks status in Google Sheets, and sends beautiful HTML email alerts before certificates expire.\n\n**\u2705 No API Rate Limits!**\nUses direct OpenSSL commands - scan unlimited domains with zero API costs or restrictions.\n\n**How it works:**\n1. Triggers on schedule (every 3 days)\n2. Reads domain list from Google Sheets\n3. Checks each domain SSL certificate via OpenSSL\n4. Parses expiration dates, issuer info, and calculates days remaining\n5. Updates Google Sheet with current status\n6. Sends styled email alert if any certs are expiring soon\n\n**Setup steps:**\n- Connect your Google Sheets OAuth2 credentials\n- Create a Google Sheet with columns: Domain, Expiry Date, Days Left, Status, Issuer, Last Checked\n- Add your domains to scan in the Domain column\n- Connect SMTP credentials and update email addresses\n\n**Configuration:**\n- Adjust ALERT_THRESHOLD_DAYS in: Prepare Domain List and Set Threshold & Parse SSL Results and Identify Expiring Certs \n(default: 20 days)"
      },
      "typeVersion": 1
    },
    {
      "id": "cb4d8533-f211-4a33-84fa-843c52debd65",
      "name": "Sticky Note - Data Input",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -3952,
        576
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 180,
        "content": "### \ud83d\udce5 Data Input\nFetch domains from Google Sheets and prepare for scanning"
      },
      "typeVersion": 1
    },
    {
      "id": "4147c3e1-0bbe-4365-82a0-a6e541ef1e97",
      "name": "Sticky Note - SSL Check",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -3456,
        576
      ],
      "parameters": {
        "color": 7,
        "width": 760,
        "height": 180,
        "content": "### \ud83d\udd0d SSL Check & Processing\nVerify certificates via OpenSSL and parse expiry results"
      },
      "typeVersion": 1
    },
    {
      "id": "e2dcbb57-8fb9-4719-a84a-a3789ab860bd",
      "name": "Sticky Note - Output",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2608,
        368
      ],
      "parameters": {
        "color": 7,
        "width": 520,
        "height": 480,
        "content": "### \ud83d\udce4 Output\nUpdate sheet and send alerts"
      },
      "typeVersion": 1
    },
    {
      "id": "364c658e-ba92-4300-9e98-efa17ff34e1c",
      "name": "Schedule Trigger (Every 3 Days at 10AM)",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -3936,
        704
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "daysInterval": 3,
              "triggerAtHour": 10
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "b8f9b5d8-d105-4043-9706-0c6236261c71",
      "name": "Read Domain List from Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -3712,
        704
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "id",
          "value": "gid=0"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR-GOOGLE-SHEET-ID"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "cce4b9aa-3c4e-4926-ab1a-1694634935e8",
      "name": "Prepare Domain List and Set Threshold",
      "type": "n8n-nodes-base.code",
      "position": [
        -3488,
        704
      ],
      "parameters": {
        "jsCode": "var ALERT_THRESHOLD_DAYS = 20;\n\nvar items = $input.all();\nvar domains = [];\n\nfor (var i = 0; i < items.length; i++) {\n  var d = items[i].json.Domain;\n  if (d && d.trim() !== '' && d !== 'Domain') {\n    domains.push(d);\n  }\n}\n\nreturn [{ json: { domains: domains, ALERT_THRESHOLD_DAYS: ALERT_THRESHOLD_DAYS } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "eeeb9ed2-b595-45d7-a63f-a36c22138699",
      "name": "Split Into Individual Domains",
      "type": "n8n-nodes-base.code",
      "position": [
        -3264,
        704
      ],
      "parameters": {
        "jsCode": "var domains = $input.first().json.domains;\nvar threshold = $input.first().json.ALERT_THRESHOLD_DAYS;\nvar output = [];\n\nfor (var i = 0; i < domains.length; i++) {\n  output.push({ json: { domain: domains[i], threshold: threshold } });\n}\n\nreturn output;"
      },
      "typeVersion": 2
    },
    {
      "id": "23ba0707-a152-4e6a-b97d-03a66ead3ec6",
      "name": "Check SSL Certificate via OpenSSL",
      "type": "n8n-nodes-base.executeCommand",
      "position": [
        -3040,
        704
      ],
      "parameters": {
        "command": "=echo | timeout 10 openssl s_client -servername {{ $json.domain }} -connect {{ $json.domain }}:443 2>&1 | openssl x509 -noout -dates -issuer 2>&1",
        "executeOnce": false
      },
      "typeVersion": 1
    },
    {
      "id": "f2bae858-855c-4b50-a56c-5d09ce880487",
      "name": "Parse SSL Results and Identify Expiring Certs",
      "type": "n8n-nodes-base.code",
      "position": [
        -2816,
        704
      ],
      "parameters": {
        "jsCode": "var ALERT_THRESHOLD_DAYS = 20;\nvar originalItems = $('Split Into Individual Domains').all();\nvar sslOutputs = $input.all();\nvar results = [];\nvar alertDomains = [];\n\nfor (var i = 0; i < sslOutputs.length; i++) {\n  var domain = originalItems[i].json.domain;\n  var output = sslOutputs[i].json.stdout;\n  if (!output) output = '';\n  \n  var result = {\n    domain: domain,\n    expiryDate: '',\n    daysLeft: '',\n    status: '',\n    issuer: '',\n    lastChecked: new Date().toISOString().split('T')[0],\n    error: ''\n  };\n  \n  if (output.indexOf('notAfter') === -1) {\n    result.status = 'ERROR';\n    result.error = 'Could not retrieve certificate';\n    alertDomains.push(result);\n  } else {\n    var notAfterMatch = output.match(/notAfter=(.+)/);\n    if (notAfterMatch) {\n      var expiryDate = new Date(notAfterMatch[1]);\n      var now = new Date();\n      var daysLeft = Math.floor((expiryDate - now) / (1000 * 60 * 60 * 24));\n      \n      result.expiryDate = expiryDate.toISOString().split('T')[0];\n      result.daysLeft = daysLeft;\n      \n      if (daysLeft <= 0) {\n        result.status = 'EXPIRED';\n        alertDomains.push(result);\n      } else {\n        result.status = 'OK';\n        if (daysLeft <= ALERT_THRESHOLD_DAYS) {\n          alertDomains.push(result);\n        }\n      }\n    }\n    \n    var issuerMatch = output.match(/issuer=.*?O\\s*=\\s*([^,\\n\\/]+)/);\n    if (issuerMatch) {\n      result.issuer = issuerMatch[1].trim();\n    } else {\n      var issuerSimple = output.match(/issuer=(.+)/);\n      if (issuerSimple) {\n        result.issuer = issuerSimple[1].substring(0, 50);\n      }\n    }\n  }\n  \n  results.push(result);\n}\n\nreturn [{\n  json: {\n    results: results,\n    alertDomains: alertDomains,\n    hasAlerts: alertDomains.length > 0,\n    totalChecked: results.length,\n    alertCount: alertDomains.length,\n    threshold: ALERT_THRESHOLD_DAYS\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "53df4d58-7990-426f-bd0c-77f58a67dfb9",
      "name": "Split Results for Sheet Update",
      "type": "n8n-nodes-base.code",
      "position": [
        -2592,
        512
      ],
      "parameters": {
        "jsCode": "var results = $input.first().json.results;\nvar output = [];\n\nfor (var i = 0; i < results.length; i++) {\n  output.push({ json: results[i] });\n}\n\nreturn output;"
      },
      "typeVersion": 2
    },
    {
      "id": "eec4d0c6-b341-4fae-a404-475a989883d9",
      "name": "Update Google Sheet with Results",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -2368,
        512
      ],
      "parameters": {
        "columns": {
          "value": {
            "Domain": "={{ $json.domain }}",
            "Issuer": "={{ $json.issuer }}",
            "Status": "={{ $json.status }}",
            "Days Left": "={{ $json.daysLeft }}",
            "Expiry Date": "={{ $json.expiryDate }}",
            "Last Checked": "={{ $json.lastChecked }}"
          },
          "schema": [
            {
              "id": "Domain",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Domain",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Expiry Date",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Expiry Date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Days Left",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Days Left",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Issuer",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Issuer",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Last Checked",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Last Checked",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "Domain"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "id",
          "value": "gid=0"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR-GOOGLE-SHEET-ID"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "2333e0e3-9886-4076-89e7-f1ae5013ac83",
      "name": "Has Expiring Certificates",
      "type": "n8n-nodes-base.if",
      "position": [
        -2592,
        800
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-alerts",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $('Parse SSL Results and Identify Expiring Certs').item.json.hasAlerts }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "e6a6ba4a-edc6-4d09-8ccf-781bd9e8b439",
      "name": "Build HTML Email Alert",
      "type": "n8n-nodes-base.code",
      "position": [
        -2368,
        704
      ],
      "parameters": {
        "jsCode": "var data = $('Parse SSL Results and Identify Expiring Certs').item.json;\nvar alertDomains = data.alertDomains;\nvar threshold = data.threshold;\n\nvar certRows = '';\nfor (var i = 0; i < alertDomains.length; i++) {\n  var cert = alertDomains[i];\n  var statusColor = '#059669';\n  var statusBg = '#d1fae5';\n  var statusText = 'OK';\n  \n  if (cert.status === 'EXPIRED') {\n    statusColor = '#dc2626';\n    statusBg = '#fee2e2';\n    statusText = 'EXPIRED';\n  } else if (cert.status === 'WARNING') {\n    statusColor = '#d97706';\n    statusBg = '#fef3c7';\n    statusText = 'WARNING';\n  } else if (cert.status === 'ERROR') {\n    statusColor = '#dc2626';\n    statusBg = '#fee2e2';\n    statusText = 'ERROR';\n  }\n  \n  var daysLeftColor = '#059669';\n  if (cert.daysLeft <= 7) daysLeftColor = '#dc2626';\n  else if (cert.daysLeft <= threshold) daysLeftColor = '#d97706';\n\n  certRows += '<tr>';\n  certRows += '<td style=\"padding: 16px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #1f2937;\">' + cert.domain + '</td>';\n  certRows += '<td style=\"padding: 16px; border-bottom: 1px solid #e5e7eb; text-align: center;\">';\n  certRows += '<span style=\"background: ' + statusBg + '; color: ' + statusColor + '; padding: 6px 12px; border-radius: 20px; font-size: 13px; font-weight: 600;\">' + statusText + '</span>';\n  certRows += '</td>';\n  certRows += '<td style=\"padding: 16px; border-bottom: 1px solid #e5e7eb; text-align: center; font-weight: 700; color: ' + daysLeftColor + '; font-size: 18px;\">';\n  certRows += cert.error ? 'N/A' : cert.daysLeft;\n  certRows += '</td>';\n  certRows += '<td style=\"padding: 16px; border-bottom: 1px solid #e5e7eb; text-align: center; color: #6b7280;\">';\n  certRows += cert.error ? cert.error : cert.expiryDate;\n  certRows += '</td>';\n  certRows += '</tr>';\n}\n\nvar currentDate = new Date().toLocaleDateString('en-US', { \n  weekday: 'long', \n  year: 'numeric', \n  month: 'long', \n  day: 'numeric' \n});\n\nvar emailHtml = '';\nemailHtml += '<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head>';\nemailHtml += '<body style=\"margin: 0; padding: 0; background-color: #f3f4f6; font-family: Arial, sans-serif;\">';\nemailHtml += '<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background-color: #f3f4f6; padding: 40px 20px;\"><tr><td align=\"center\">';\nemailHtml += '<table width=\"700\" cellpadding=\"0\" cellspacing=\"0\">';\nemailHtml += '<tr><td style=\"background-color: #1e3a5f; border-radius: 16px 16px 0 0; padding: 40px 32px; text-align: center;\">';\nemailHtml += '<h1 style=\"color: #ffffff; margin: 0 0 8px 0; font-size: 28px;\">SSL Certificate Alert</h1>';\nemailHtml += '<p style=\"color: #94a3b8; margin: 0; font-size: 15px;\">' + currentDate + '</p>';\nemailHtml += '</td></tr>';\nemailHtml += '<tr><td style=\"background-color: #ffffff; padding: 32px;\">';\nemailHtml += '<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background-color: #fef3c7; border-radius: 12px; border-left: 4px solid #f59e0b; margin-bottom: 32px;\"><tr><td style=\"padding: 24px;\">';\nemailHtml += '<span style=\"font-weight: 700; color: #92400e; font-size: 18px;\">' + alertDomains.length + ' Certificate' + (alertDomains.length > 1 ? 's' : '') + ' Require' + (alertDomains.length === 1 ? 's' : '') + ' Attention</span>';\nemailHtml += '<p style=\"color: #a16207; font-size: 14px; margin: 8px 0 0 0;\">Threshold: ' + threshold + ' days before expiration</p>';\nemailHtml += '</td></tr></table>';\nemailHtml += '<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin-bottom: 32px;\"><tr>';\nemailHtml += '<td width=\"32%\" style=\"background-color: #f8fafc; border-radius: 12px; padding: 24px; text-align: center; border: 1px solid #e2e8f0;\">';\nemailHtml += '<div style=\"font-size: 36px; font-weight: 700; color: #1e3a5f;\">' + data.totalChecked + '</div>';\nemailHtml += '<div style=\"color: #64748b; font-size: 13px; text-transform: uppercase; margin-top: 8px;\">Domains Checked</div></td>';\nemailHtml += '<td width=\"2%\"></td>';\nemailHtml += '<td width=\"32%\" style=\"background-color: #fef2f2; border-radius: 12px; padding: 24px; text-align: center; border: 1px solid #fecaca;\">';\nemailHtml += '<div style=\"font-size: 36px; font-weight: 700; color: #dc2626;\">' + data.alertCount + '</div>';\nemailHtml += '<div style=\"color: #64748b; font-size: 13px; text-transform: uppercase; margin-top: 8px;\">Alerts</div></td>';\nemailHtml += '<td width=\"2%\"></td>';\nemailHtml += '<td width=\"32%\" style=\"background-color: #f0fdf4; border-radius: 12px; padding: 24px; text-align: center; border: 1px solid #bbf7d0;\">';\nemailHtml += '<div style=\"font-size: 36px; font-weight: 700; color: #059669;\">' + (data.totalChecked - data.alertCount) + '</div>';\nemailHtml += '<div style=\"color: #64748b; font-size: 13px; text-transform: uppercase; margin-top: 8px;\">Healthy</div></td>';\nemailHtml += '</tr></table>';\nemailHtml += '<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"border: 1px solid #e5e7eb; border-radius: 12px;\">';\nemailHtml += '<tr style=\"background-color: #f8fafc;\">';\nemailHtml += '<th style=\"padding: 14px 16px; text-align: left; font-weight: 600; color: #475569; font-size: 13px; text-transform: uppercase; border-bottom: 2px solid #e5e7eb;\">Domain</th>';\nemailHtml += '<th style=\"padding: 14px 16px; text-align: center; font-weight: 600; color: #475569; font-size: 13px; text-transform: uppercase; border-bottom: 2px solid #e5e7eb;\">Status</th>';\nemailHtml += '<th style=\"padding: 14px 16px; text-align: center; font-weight: 600; color: #475569; font-size: 13px; text-transform: uppercase; border-bottom: 2px solid #e5e7eb;\">Days Left</th>';\nemailHtml += '<th style=\"padding: 14px 16px; text-align: center; font-weight: 600; color: #475569; font-size: 13px; text-transform: uppercase; border-bottom: 2px solid #e5e7eb;\">Expiry Date</th>';\nemailHtml += '</tr>';\nemailHtml += certRows;\nemailHtml += '</table></td></tr>';\nemailHtml += '<tr><td style=\"background-color: #1e293b; border-radius: 0 0 16px 16px; padding: 32px; text-align: center;\">';\nemailHtml += '<p style=\"color: #a78bfa; font-weight: 700; font-size: 16px; margin: 0 0 16px 0;\">Powered by n8n Automation</p>';\nemailHtml += '<p style=\"color: #94a3b8; font-size: 13px; margin: 0 0 8px 0;\">This is an automated SSL monitoring alert.</p>';\nemailHtml += '<p style=\"color: #64748b; font-size: 12px; margin: 0;\">Checks run every 3 days to keep your certificates secure.</p>';\nemailHtml += '</td></tr></table></td></tr></table></body></html>';\n\nvar timestamp = new Date().toLocaleString('en-US', { \n  month: 'short', \n  day: 'numeric',\n  hour: '2-digit', \n  minute: '2-digit'\n});\n\nvar subject = 'SSL Alert: ' + alertDomains.length + ' certificate' + (alertDomains.length > 1 ? 's' : '') + ' expiring soon - ' + timestamp;\n\nreturn [{ json: { subject: subject, emailHtml: emailHtml, emailBody: emailHtml } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "b9b99ed3-03fd-4c2f-9c1c-5adaaa5712d3",
      "name": "Send Alert Email via SMTP",
      "type": "n8n-nodes-base.emailSend",
      "position": [
        -2144,
        704
      ],
      "parameters": {
        "html": "={{ $json.emailHtml }}",
        "text": "={{ $json.emailHtml }}",
        "options": {},
        "subject": "={{ $json.subject }}",
        "emailFormat": "both"
      },
      "credentials": {
        "smtp": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "faef2e13-e729-48ab-a721-6dedc39761f3",
      "name": "No Alerts Needed (All Certs OK)",
      "type": "n8n-nodes-base.noOp",
      "position": [
        -2368,
        896
      ],
      "parameters": {},
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "edf99c25-cf1a-4acd-bb16-83ebc244ff5f",
  "connections": {
    "Build HTML Email Alert": {
      "main": [
        [
          {
            "node": "Send Alert Email via SMTP",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Expiring Certificates": {
      "main": [
        [
          {
            "node": "Build HTML Email Alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No Alerts Needed (All Certs OK)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Into Individual Domains": {
      "main": [
        [
          {
            "node": "Check SSL Certificate via OpenSSL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Results for Sheet Update": {
      "main": [
        [
          {
            "node": "Update Google Sheet with Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check SSL Certificate via OpenSSL": {
      "main": [
        [
          {
            "node": "Parse SSL Results and Identify Expiring Certs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Domain List from Google Sheets": {
      "main": [
        [
          {
            "node": "Prepare Domain List and Set Threshold",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Domain List and Set Threshold": {
      "main": [
        [
          {
            "node": "Split Into Individual Domains",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger (Every 3 Days at 10AM)": {
      "main": [
        [
          {
            "node": "Read Domain List from Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse SSL Results and Identify Expiring Certs": {
      "main": [
        [
          {
            "node": "Has Expiring Certificates",
            "type": "main",
            "index": 0
          },
          {
            "node": "Split Results for Sheet Update",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}