A lightweight, federated deadman switch. Users check in periodically via TOTP; if they stop, the system progresses through escalating alerts and eventually triggers actions — sending notifications, running scripts, and executing a recurring post-trigger schedule.
- TOTP authentication: Works with any authenticator app (Google Authenticator, Authy, etc.)
- Federated: Multiple nodes sync check-ins; any single node can trigger (failure mode: duplicates, not silence)
- Multi-stage escalation: Configurable warning → grace → trigger pipeline with callbacks at each stage
- Post-trigger scheduling: Recurring actions after trigger — annual emails, birthday messages, periodic scripts
- 80+ notification services: Via Apprise — ntfy, email, Slack, Discord, Telegram, Pushbullet, Gotify, and more
- Script execution: Run Python or shell scripts with full event context via environment variables and JSON
- Web interface: Dark-themed check-in form with status display
- JSON API: Programmatic check-in and status endpoints
- Portable: Runs on laptop, Raspberry Pi, VPS — anywhere Python 3.10+ is available
pip install posthumousgit clone https://github.com/queelius/posthumous.git
cd posthumous
pip install -e ".[dev]"pip install -e ".[dev]"
pytest # Run all tests (448 tests, 98% coverage)# 1. Initialize a new node — generates TOTP secret and shows QR code
posthumous init --node-name laptop
# 2. Scan the QR code with your authenticator app
# 3. Start the daemon
posthumous run
# 4. Check in periodically
posthumous checkin
# or use the short alias
phm checkinAfter initialization, your config lives at ~/.posthumous/config.yaml. Edit it to add notification channels, peers, and post-trigger actions.
Configuration is stored in ~/.posthumous/config.yaml. All fields with their defaults:
| Option | Default | Description |
|---|---|---|
node_name |
(required) | Name for this node (used in logs and notifications) |
secret_key |
(required) | Base32 TOTP secret (generated by init) |
listen |
0.0.0.0:8420 |
HTTP server bind address (host:port) |
api_token |
null |
Optional API token for automated check-ins |
All timing values accept human-readable durations: 7 days, 12 hours, 30 minutes, 1 week.
| Option | Default | Description |
|---|---|---|
checkin_interval |
7 days |
Expected check-in frequency (used for "overdue" warnings) |
warning_start |
8 days |
Time since last check-in to enter WARNING state |
grace_start |
12 days |
Time since last check-in to enter GRACE state |
trigger_at |
14 days |
Time since last check-in to TRIGGER (irreversible) |
| Option | Default | Description |
|---|---|---|
max_failed_attempts |
5 |
Failed auth attempts before lockout |
lockout_duration |
15 minutes |
Lockout duration after max failed attempts |
| Option | Default | Description |
|---|---|---|
peers |
[] |
List of peer node URLs (e.g., https://server:8420) |
peer_check_interval |
30 minutes |
How often to health-check peers |
peer_down_threshold |
6 hours |
How long before alerting about a down peer |
Channels are named groups of Apprise URLs:
notifications:
default:
- "ntfy://my-topic"
urgent:
- "ntfy://my-topic"
- "mailto://user:pass@smtp.gmail.com?to=contact@example.com"
family:
- "tgram://bot_token/chat_id"Actions fire at each state transition. Each action is either a notification or a script:
actions:
on_warning:
- notify: default
message: "Check-in needed. {days_left} days remaining."
on_grace:
- notify: urgent
message: "URGENT: Posthumous triggers in {hours_left} hours."
on_trigger:
- notify: urgent
message: "Posthumous has activated."
- script: "scripts/on_trigger.py"Template variables available in messages:
| Variable | Description |
|---|---|
{node_name} |
This node's name |
{status} |
Current status |
{days_left} |
Days until trigger |
{hours_left} |
Hours until trigger |
{last_checkin} |
Last check-in timestamp |
{trigger_time} |
When the trigger fired |
After trigger, these items run on a recurring schedule:
post_trigger:
- name: "annual_letter"
when: "every year on trigger"
script: "scripts/annual_letter.py"
- name: "birthday_message"
when: "every March 15"
notify: default
message: "Happy birthday. Thinking of you always."
- name: "weekly_check"
when: "every week after trigger"
script: "scripts/weekly_maintenance.py"
- name: "one_time_upload"
when: "trigger + 1 day"
script: "scripts/upload_files.py"node_name: "laptop"
secret_key: "JBSWY3DPEHPK3PXP"
listen: "0.0.0.0:8420"
api_token: "my-automation-token-here"
checkin_interval: 7 days
warning_start: 8 days
grace_start: 12 days
trigger_at: 14 days
peers:
- https://backup-server.home:8420
- https://vps.example.com:8420
notifications:
default:
- "ntfy://posthumous-alerts"
urgent:
- "ntfy://posthumous-alerts"
- "mailto://user:pass@smtp.gmail.com?to=family@example.com"
actions:
on_warning:
- notify: default
message: "Check-in needed. {days_left} days remaining before trigger."
on_grace:
- notify: urgent
message: "URGENT: Posthumous triggers in {hours_left} hours."
on_trigger:
- notify: urgent
message: "Posthumous has activated for node {node_name}."
- script: "scripts/on_trigger.py"
post_trigger:
- name: "annual_letter"
when: "every year on trigger"
script: "scripts/annual_letter.py"
- name: "birthday_message"
when: "every March 15"
notify: default
message: "Happy birthday. Thinking of you always."
- name: "immediate_upload"
when: "trigger"
script: "scripts/upload_encrypted.py"ARMED ──timeout──► WARNING ──timeout──► GRACE ──timeout──► TRIGGERED
▲ │ │ │
└───── check-in ────┴───── check-in ────┘ │
▼
(scheduler runs forever)
| State | Description |
|---|---|
| ARMED | Normal operation. Timer counting since last check-in. |
| WARNING | First escalation. Fires on_warning actions. Check-in still resets to ARMED. |
| GRACE | Final warning. Fires on_grace actions. Check-in still resets to ARMED. |
| TRIGGERED | Terminal state. Fires on_trigger actions. No check-in can undo it. Post-trigger scheduler starts. |
If a node is offline and misses intermediate states (e.g., goes from ARMED straight to TRIGGERED), the watchdog fires all intermediate callbacks in order before reaching the current state.
The when field in post-trigger items supports:
"trigger" # At trigger time
"trigger + 3 days" # 3 days after trigger
"trigger + 1 hour" # 1 hour after trigger"every day after trigger" # Daily from trigger
"every week after trigger" # Weekly from trigger
"every month after trigger" # Monthly from trigger
"every 30 days after trigger" # Custom interval"every year on trigger" # Annual trigger anniversary"2030-01-01" # Specific date"every December 25" # Yearly on Christmas
"every March 15" # Yearly on date
"every March 15 - 7 days" # 7 days before March 15 each yearEach scheduled execution is deduplicated with a period key (e.g., "2026" for annual, "2026-W05" for weekly, "once" for one-time events) to prevent repeats across restarts or federated nodes.
Posthumous uses Apprise for notifications. Here are popular services with their URL formats:
notifications:
alerts:
- "ntfy://my-private-topic"
- "ntfy://user:pass@ntfy.example.com/topic"notifications:
email:
- "mailto://user:pass@smtp.gmail.com?to=recipient@example.com"
- "mailto://user:pass@smtp.gmail.com?to=person1@example.com,person2@example.com"notifications:
slack:
- "slack://TokenA/TokenB/TokenC/#channel"notifications:
discord:
- "discord://webhook_id/webhook_token/"notifications:
telegram:
- "tgram://bot_token/chat_id"notifications:
gotify:
- "gotify://hostname/token"notifications:
pushbullet:
- "pbul://access_token"See the Apprise wiki for the full list of 80+ supported services.
Scripts are executed asynchronously with a 300-second (5 minute) default timeout.
Scripts receive these environment variables:
| Variable | Description |
|---|---|
POSTHUMOUS_EVENT |
Event type: warning, grace, trigger, or scheduled |
POSTHUMOUS_NODE |
Node name |
POSTHUMOUS_STATUS |
Current status |
POSTHUMOUS_TRIGGER_TIME |
ISO 8601 trigger timestamp (if triggered) |
POSTHUMOUS_LAST_CHECKIN |
ISO 8601 last check-in timestamp |
POSTHUMOUS_SCHEDULE_ITEM |
Name of scheduled item (if scheduled event) |
POSTHUMOUS_CONTEXT_FILE |
Path to JSON file with full context |
A temporary JSON file with complete context is created for each script execution and auto-cleaned after:
{
"event": "trigger",
"trigger_time": "2026-02-10T14:30:00+00:00",
"node_name": "laptop",
"status": "triggered",
"last_checkin": "2026-01-27T09:15:00+00:00",
"schedule_item": null,
"extra": {}
}#!/usr/bin/env python3
"""Upload encrypted files when triggered."""
import json
import os
import subprocess
def main():
context_file = os.environ.get("POSTHUMOUS_CONTEXT_FILE")
if context_file:
with open(context_file) as f:
context = json.load(f)
event = os.environ.get("POSTHUMOUS_EVENT")
node = os.environ.get("POSTHUMOUS_NODE")
if event == "trigger":
# Upload encrypted archive to cloud storage
subprocess.run(["rclone", "copy", "/encrypted/vault", "remote:backup/"])
print(f"Vault uploaded from {node}")
return 0
if __name__ == "__main__":
exit(main())Scripts must be executable (chmod +x) or be Python files (.py extension, run with the current Python interpreter). Place them in ~/.posthumous/scripts/ or use absolute paths.
When running, Posthumous serves a web interface and JSON API.
GET /— Redirects to/checkinGET /checkin— Dark-themed check-in form with status displayPOST /checkin— Submit TOTP code (form or JSON)
Check in:
# With TOTP code
curl -X POST http://localhost:8420/checkin \
-H "Content-Type: application/json" \
-d '{"totp": "123456"}'
# With API token
curl -X POST http://localhost:8420/checkin \
-H "Content-Type: application/json" \
-d '{"token": "my-api-token"}'Response:
{
"success": true,
"status": "armed",
"next_deadline": "2026-02-24T14:30:00+00:00"
}Get status:
curl http://localhost:8420/statusResponse:
{
"node_name": "laptop",
"status": "armed",
"last_checkin": "2026-02-10T14:30:00+00:00",
"trigger_time": null,
"time_remaining": {
"until_warning": 172800.0,
"until_grace": 518400.0,
"until_trigger": 691200.0
}
}Health check:
curl http://localhost:8420/healthThese are used by federated nodes (HMAC-signed):
| Endpoint | Method | Description |
|---|---|---|
/sync/checkin |
POST | Receive check-in broadcast from peer |
/sync/trigger |
POST | Receive trigger broadcast from peer |
/sync/scheduled |
POST | Receive scheduled item completion from peer |
/sync/state |
GET | Return current state for peer sync |
Multiple nodes form a federation by sharing the same TOTP secret and listing each other as peers.
# On the first node
posthumous init --node-name primary
# On additional nodes — join with the shared secret
posthumous init --node-name backup --join https://primary:8420
# Enter the secret from the primary node when promptedOr manually set the same secret_key and add peers: to each node's config.
- Check-in broadcast: When any node accepts a check-in, it broadcasts to all peers. Peers apply the check-in locally, resetting their timers.
- Trigger broadcast: When any node triggers, it broadcasts the trigger event. All peers transition to TRIGGERED.
- Scheduled item sync: When a node completes a scheduled item, it broadcasts completion so peers skip it (deduplication).
- Health monitoring: Background loop checks peer status every
peer_check_interval. Alerts afterpeer_down_threshold.
- Network partition: Nodes operate independently. If one triggers, the other won't know until connectivity restores. Both may trigger independently — this is by design (duplicates > silence).
- All peers down: The surviving node operates normally and triggers on its own.
- Split brain: Multiple nodes may fire the same actions. Period-based dedup keys prevent repeats on the same node.
All peer sync messages are signed with HMAC-SHA256 using the shared secret_key:
signature = HMAC-SHA256(secret_key, "checkin:<timestamp>")
signature = HMAC-SHA256(secret_key, "trigger:<timestamp>")
signature = HMAC-SHA256(secret_key, "scheduled:<item_name>:<period>")
Posthumous uses RFC 6238 TOTP with 6-digit codes and a 30-second time step. The secret is generated as 32-character Base32 during init.
After max_failed_attempts (default: 5) failed authentication attempts within lockout_duration (default: 15 minutes), the node locks out further attempts for lockout_duration.
For automation, set api_token in config. Use with posthumous checkin --token <token> or via the JSON API. The token provides the same access as a valid TOTP code — use with caution.
All peer communication is signed with the shared TOTP secret using HMAC-SHA256. Invalid signatures are rejected and logged.
Both posthumous and phm are available as entry points.
posthumous [OPTIONS] COMMAND
Options:
--version Show version
-v, --verbose Enable debug logging
-c, --config PATH Custom config file path
Commands:
init Initialize a new node
--node-name TEXT Node name (required)
--join URL Join existing federation
config Configuration management
path Show file locations
show Print config (secret redacted)
validate Check config for errors
edit Open in $EDITOR, validate on close
run Start the daemon
-d, --daemon Background mode (not yet implemented)
checkin Check in to reset timer
-t, --token TEXT Use API token instead of TOTP
status Show current status and time remaining
peers Show peer status
test-notify Send test notifications
-c, --channel Test specific channel (default: all)
test-trigger Dry-run trigger actions
export PATH Export state and config to YAML
import PATH Import state from YAML backup
"Config not found"
Run posthumous init --node-name <name> to create the initial config.
"secret_key must be valid base32"
The secret key was edited manually and is no longer valid Base32. Re-run posthumous init or use a proper Base32 string.
"locked out" Too many failed attempts. Wait for the lockout duration (default: 15 minutes) or restart the daemon.
Notifications not sending
Test with posthumous test-notify. Check that your Apprise URLs are correct. See the Apprise wiki for URL formats.
| File | Location |
|---|---|
| Config | ~/.posthumous/config.yaml |
| State | ~/.posthumous/state.yaml |
| Logs | ~/.posthumous/logs/posthumous.log |
| Scripts | ~/.posthumous/scripts/ |
Use posthumous config path to see the actual paths on your system.
If state is corrupted, restore from a backup:
posthumous import backup.yamlOr delete ~/.posthumous/state.yaml to start fresh (timer resets, but TRIGGERED state is lost).
With federation, a recovering node can sync state from peers automatically.
When running with -v (verbose), debug-level logs are written to both stdout and ~/.posthumous/logs/posthumous.log.
Create /etc/systemd/system/posthumous.service:
[Unit]
Description=Posthumous Deadman Switch
After=network.target
[Service]
Type=simple
User=your-username
ExecStart=/usr/local/bin/posthumous run
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.targetThen:
sudo systemctl enable posthumous
sudo systemctl start posthumous
sudo systemctl status posthumousMIT