Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ dist/
Dockerfile
docker-compose.yml
.dockerignore
.github/workflows/

# Dokumentationsverzeichnisse
docs/
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 📦 Paperless Backup

An automated Docker-based backup system for [Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx) with email notifications and intelligent retention policies.
An automated Docker-based backup system for [Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx) with email notifications and retention policies.

![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white)
![Python](https://img.shields.io/badge/Python-3.14-3776ab?style=for-the-badge&logo=python&logoColor=white)
Expand Down Expand Up @@ -40,8 +40,10 @@ services:
PAPERLESS_EXPORT_DIR: '../export'
BACKUP_DIR: '/backups'
EXPORT_DIR: '/export'
BACKUP_PREFIX: 'backup'
BACKUP_SCHEDULE: "0 2 * * *" # Optional: Daily at 02:00
KEEP_BACKUPS: 7
BACKUP_ON_STARTUP: False

# Optional: SMTP Configuration
# SMTP_SERVER: smtp.example.com
Expand All @@ -68,8 +70,10 @@ services:
| `PAPERLESS_CONTAINER_NAME` | `paperless` | Name of the Paperless Docker container |
| `PAPERLESS_EXPORT_DIR` | `../export` | Export directory inside Paperless container |
| `BACKUP_DIR` | `/backup` | Target directory for backups |
| `BACKUP_PREFIX` | `backup` | Prefix of the backup files |
| `EXPORT_DIR` | `/export` | Local export directory |
| `KEEP_BACKUPS` | `7` | Number of backup versions to keep |
| `BACKUP_ON_STARTUP` | `False` | Run a backup on startup |

### Optional: SMTP Configuration

Expand Down Expand Up @@ -115,7 +119,7 @@ This project is licensed under the [MIT License](LICENSE).

## 📞 Support

- 🐛 [Issues](https://github.com/yourusername/paperless-backup/issues)
- 🐛 [Issues](https://github.com/rossberi/paperless-backup/issues)

## 📚 Resources

Expand Down
20 changes: 10 additions & 10 deletions app/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

from app.log import log_msg

def export_paperless(container_name: str, paperless_export_dir: str, timestamp: str):
def export_paperless(container_name: str, paperless_export_dir: str, timestamp: str, backup_prefix: str):
client = docker.from_env()
paperless = client.containers.get(container_name)
export_cmd = f"document_exporter -z -zn backup_{timestamp} {paperless_export_dir}"
export_cmd = f"document_exporter -z -zn {backup_prefix}_{timestamp} {paperless_export_dir}"
exec_result = paperless.exec_run(export_cmd, stdout=True, stderr=True)
log_output = exec_result.output.decode("utf-8")
log_msg("")
Expand All @@ -22,23 +22,23 @@ def export_paperless(container_name: str, paperless_export_dir: str, timestamp:
if exec_result.exit_code != 0:
raise Exception(f"ERROR - Failed to export documents. Exit-Code: {exec_result.exit_code}")

def copy_exported_zip(timestamp: str, paperless_export_dir: str, paperless_backup_dir: str):
exported_zip = f'{paperless_export_dir}/backup_{timestamp}.zip'
def copy_exported_zip(timestamp: str, paperless_export_dir: str, paperless_backup_dir: str, backup_prefix: str):
exported_zip = f'{paperless_export_dir}/{backup_prefix}_{timestamp}.zip'

if not os.path.exists(exported_zip):
raise FileNotFoundError(f"ERROR - Export failed – File not found: {exported_zip}")
shutil.move(exported_zip, f'{paperless_backup_dir}/backup_{timestamp}.zip')
shutil.move(exported_zip, f'{paperless_backup_dir}/{backup_prefix}_{timestamp}.zip')

file_size = round((os.path.getsize(f'{paperless_backup_dir}/backup_{timestamp}.zip') / 1024 / 1024 / 1024), 2)
file_size = round((os.path.getsize(f'{paperless_backup_dir}/{backup_prefix}_{timestamp}.zip') / 1024 / 1024 / 1024), 2)

log_msg(f"Created backup: backup_{timestamp}.zip | Size: {file_size} GB")
log_msg(f"Created backup: {backup_prefix}_{timestamp}.zip | Size: {file_size} GB")
log_msg("")

def start_backup(container_name: str, paperless_export_dir: str,backup_dir: str):
def start_backup(container_name: str, paperless_export_dir: str,backup_dir: str, backup_prefix: str):
timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")

log_msg("Paperless export started ...")
export_paperless(container_name, paperless_export_dir, timestamp)
export_paperless(container_name, paperless_export_dir, timestamp, backup_prefix)

log_msg("Copying exported zip ...")
copy_exported_zip(timestamp, paperless_export_dir, backup_dir)
copy_exported_zip(timestamp, paperless_export_dir, backup_dir, backup_prefix)
37 changes: 37 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import os

class Config:
# Paperless
PAPERLESS_CONTAINER_NAME = os.getenv("PAPERLESS_CONTAINER_NAME", "paperless")
PAPERLESS_EXPORT_DIR = os.getenv("PAPERLESS_EXPORT_DIR", "../export")

# Backup
BACKUP_DIR: str = os.getenv("BACKUP_DIR", "/backup")
EXPORT_DIR: str = os.getenv("EXPORT_DIR", "/export")
KEEP_BACKUPS: int = int(os.getenv("KEEP_BACKUPS", "7"))
BACKUP_PREFIX: str = os.getenv("BACKUP_PREFIX", "backup")
BACKUP_ON_STARTUP: str = os.getenv("BACKUP_ON_STARTUP", "False")

# SMTP
SMTP_SERVER: str = os.getenv("SMTP_SERVER")
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME: str = os.getenv("SMTP_USERNAME")
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD")
SMTP_SENDER: str = os.getenv("SMTP_SENDER")
SMTP_RECIPIENT: str = os.getenv("SMTP_RECIPIENT")
SMTP_SUBJECT_SUCCESS: str = os.getenv("SMTP_SUBJECT_SUCCESS", "Paperless backup successful")
SMTP_SUBJECT_FAILURE: str = os.getenv("SMTP_SUBJECT_FAILURE", "Paperless backup failed")
SMTP_SECURITY: str = os.getenv("SMTP_SECURITY", "starttls")

@staticmethod
def validate():
"""Validate required configuration"""
if not os.path.exists(Config.BACKUP_DIR):
raise FileNotFoundError(f"ERROR - Backup directory not found: {Config.BACKUP_DIR}")

if not os.path.exists(Config.EXPORT_DIR):
raise FileNotFoundError(f"ERROR - Paperless export directory not found: {Config.EXPORT_DIR}")

if Config.SMTP_SERVER is not None:
if not all([Config.SMTP_USERNAME, Config.SMTP_PASSWORD, Config.SMTP_SENDER, Config.SMTP_RECIPIENT]):
raise ValueError("ERROR - SMTP configuration is incomplete. Please set SMTP_USERNAME, SMTP_PASSWORD, SMTP_SENDER, and SMTP_RECIPIENT environment variables.")
90 changes: 63 additions & 27 deletions app/mail.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import smtplib
import socket
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from app.log import log_msg


def send_mail(
Expand All @@ -14,30 +16,64 @@ def send_mail(
body,
security="starttls", # "ssl", "starttls", "plain"
):
msg = MIMEMultipart()
msg["From"] = sender
msg["To"] = recipient
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))

# SSL / TLS
if security == "ssl":
with smtplib.SMTP_SSL(smtp_server, smtp_port) as server:
server.login(username, password)
server.send_message(msg)

elif security == "starttls":
with smtplib.SMTP(smtp_server, smtp_port) as server:
server.ehlo()
server.starttls()
server.ehlo()
server.login(username, password)
server.send_message(msg)

elif security == "plain":
with smtplib.SMTP(smtp_server, smtp_port) as server:
server.login(username, password)
server.send_message(msg)

else:
raise ValueError(f"Unknown security option: {security}")

# Validate security option
if security not in ["ssl", "starttls", "plain"]:
log_msg(f"ERROR - Unknown SMTP security option: {security}")
return False

try:
# Build email message
msg = MIMEMultipart()
msg["From"] = sender
msg["To"] = recipient
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))

log_msg(f"Sending email to {recipient} via {smtp_server}:{smtp_port} ({security})...")

# Send email based on security type
if security == "ssl":
with smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=10) as server:
server.login(username, password)
server.send_message(msg)

elif security == "starttls":
with smtplib.SMTP(smtp_server, smtp_port, timeout=10) as server:
server.ehlo()
server.starttls()
server.ehlo()
server.login(username, password)
server.send_message(msg)

elif security == "plain":
with smtplib.SMTP(smtp_server, smtp_port, timeout=10) as server:
server.login(username, password)
server.send_message(msg)

log_msg("Email sent successfully")
return True

except smtplib.SMTPAuthenticationError as e:
log_msg(f"ERROR - SMTP authentication failed: {str(e)}")
return False

except smtplib.SMTPException as e:
log_msg(f"ERROR - SMTP error occurred: {str(e)}")
return False

except socket.timeout:
log_msg(f"ERROR - SMTP connection timeout ({smtp_server}:{smtp_port})")
return False

except socket.gaierror:
log_msg(f"ERROR - Could not resolve SMTP server: {smtp_server}")
return False

except ConnectionRefusedError:
log_msg(f"ERROR - SMTP server refused connection: {smtp_server}:{smtp_port}")
return False

except Exception as e:
log_msg(f"ERROR - Unexpected error while sending email: {type(e).__name__}: {str(e)}")
return False
4 changes: 2 additions & 2 deletions app/retentionpolicy.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import os
from app.log import log_msg

def retention_policy(keep_backups: int, backup_dir: str):
def retention_policy(keep_backups: int, backup_dir: str, backup_prefix: str):
if keep_backups > 0:
backups = [f for f in os.listdir(backup_dir) if f.startswith("backup_") and f.endswith(".zip")]
backups = [f for f in os.listdir(backup_dir) if f.startswith(f"{backup_prefix}_") and f.endswith(".zip")]
backups.sort()
if len(backups) > keep_backups:
log_msg(f"Delete old backups > {keep_backups} versions ...")
Expand Down
6 changes: 6 additions & 0 deletions dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,11 @@ RUN apk add --no-cache dcron \

COPY . .

RUN chmod +x /code/start.sh \
&& chmod +x /code/healthcheck.py

HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \
CMD python3 /code/healthcheck.py

RUN chmod +x /code/start.sh
CMD ["/code/start.sh"]
29 changes: 29 additions & 0 deletions healthcheck.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Simple health check for Paperless Backup container."""

import os
import sys

def main():
# Check backup directory
backup_dir = os.getenv("BACKUP_DIR", "/backup")
if not os.path.exists(backup_dir) or not os.access(backup_dir, os.W_OK):
print(f"ERROR - Backup directory not accessible: {backup_dir}")
return 1

# Check export directory
export_dir = os.getenv("EXPORT_DIR", "/export")
if not os.path.exists(export_dir) or not os.access(export_dir, os.R_OK):
print(f"ERROR - Export directory not accessible: {export_dir}")
return 1

# Check Docker socket
if not os.path.exists("/var/run/docker.sock"):
print("ERROR - Docker socket not found")
return 1

print("OK - Health check passed")
return 0

if __name__ == "__main__":
sys.exit(main())
57 changes: 17 additions & 40 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,61 +4,38 @@
from app.retentionpolicy import retention_policy
from app.mail import send_mail
from app.log import log_msg, log
from app.config import Config

success = True


#environment variables
container_name = os.getenv("PAPERLESS_CONTAINER_NAME", "paperless")
paperless_export_dir = os.getenv("PAPERLESS_EXPORT_DIR", "../export")
keep_backups = int(os.getenv("KEEP_BACKUPS", "3"))
backup_dir = os.getenv("BACKUP_DIR", "/backup")
export_dir = os.getenv("EXPORT_DIR", "/export")

smtp_server = os.getenv("SMTP_SERVER")
smtp_port = int(os.getenv("SMTP_PORT", "587"))
username = os.getenv("SMTP_USERNAME")
password = os.getenv("SMTP_PASSWORD")
sender = os.getenv("SMTP_SENDER")
recipient = os.getenv("SMTP_RECIPIENT")
subject_success = os.getenv("SMTP_SUBJECT_SUCCESS", "Paperless backup successful")
subject_failure = os.getenv("SMTP_SUBJECT_FAILURE", "Paperless backup failed")
security = os.getenv("SMTP_SECURITY", "starttls")


if smtp_server is not None:
if username is None or password is None or sender is None or recipient is None:
raise ValueError("ERROR - SMTP configuration is incomplete. Please set SMTP_USERNAME, SMTP_PASSWORD, SMTP_SENDER, and SMTP_RECIPIENT environment variables.")

if not os.path.exists(backup_dir):
raise FileNotFoundError(f"ERROR - Backup directory not found: {backup_dir}")

if not os.path.exists(export_dir):
raise FileNotFoundError(f"ERROR - Paperless export directory not found: {export_dir}")
Config.validate()

try:
#start backup process
log_msg(f"Paperless Backup started ...")
start_backup(container_name, paperless_export_dir, backup_dir)
start_backup(container_name=Config.PAPERLESS_CONTAINER_NAME, paperless_export_dir=Config.PAPERLESS_EXPORT_DIR, backup_dir=Config.BACKUP_DIR, backup_prefix=Config.BACKUP_PREFIX)

#start retention policy
retention_policy(keep_backups=keep_backups, backup_dir=backup_dir)
retention_policy(keep_backups=Config.KEEP_BACKUPS, backup_dir=Config.BACKUP_DIR, backup_prefix=Config.BACKUP_PREFIX)
except Exception as e:
log_msg(str(e))
success = False

subject = subject_success if success else subject_failure
# Send mail with log
subject = Config.SMTP_SUBJECT_SUCCESS if success else Config.SMTP_SUBJECT_FAILURE
mail_body = "\n".join(log)

if smtp_server is not None:
if Config.SMTP_SERVER is not None:
send_mail(
smtp_server,
smtp_port,
username,
password,
sender,
recipient,
Config.SMTP_SERVER,
Config.SMTP_PORT,
Config.SMTP_USERNAME,
Config.SMTP_PASSWORD,
Config.SMTP_SENDER,
Config.SMTP_RECIPIENT,
subject,
mail_body,
security,
)
Config.SMTP_SECURITY,
)
else:
log_msg("No SMTP_SERVER configured, skipping email notification")
7 changes: 6 additions & 1 deletion start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ if [ -n "$BACKUP_SCHEDULE" ]; then
printf "BACKUP_SCHEDULE detected: $BACKUP_SCHEDULE\n"
printf "\n"

# Check if Backup_ON_STARTUP is set to run a backup on startup
if [ "$(echo "$BACKUP_ON_STARTUP" | tr '[:upper:]' '[:lower:]')" = "true" ]; then
/usr/local/bin/python3 /code/main.py
fi

# create crontab
echo "$BACKUP_SCHEDULE /usr/local/bin/python3 /code/main.py 2>&1" > /etc/crontabs/root
echo "$BACKUP_SCHEDULE /usr/local/bin/python3 /code/main.py >> /proc/1/fd/1 2>&1" > /etc/crontabs/root
crond -f -L /dev/stdout

else
Expand Down
Loading