Infrastructure-as-code for a self-hosted Paperless-ngx setup on Hetzner Cloud, managed with OpenTofu (Terraform). The stack includes Traefik as a reverse proxy, Proton Bridge for email ingestion, Paperless AI for automatic document tagging, automated backups to Bunny CDN, and Tailscale for secure remote access.
- Mise installed locally (mise installs opentofu for you)
- A Hetzner Cloud account
- A Tailscale account
- A Bunny CDN account
- A domain with DNS pointing to the Hetzner VPS IP
- Create a new project in the Hetzner Cloud Console
- Navigate to Security → API Tokens and generate a token with Read & Write permissions
- Copy the token - this is your
hcloud_token
- Create a Tailscale account
- Go to Settings → Keys and generate an Auth Key (reusable, ephemeral)
- Copy the key - this is your
tailscale_key - After the VPS is provisioned, find the machine in the Tailscale admin console and disable key expiry so the server stays connected permanently
- Create a Bunny CDN account
- Go to Storage and create a new storage zone
- Tier: Standard
- Replication: one replication region of your choice
- Copy the storage zone name - this is
bunnycdn.storage_zone - Open the storage zone and go to the FTP & API Access tab
- Copy the Password (not the main account API key) — this is
bunnycdn.api_key
Before or after the first tofu apply, the Proton Bridge container data directory must be initialized on the server. SSH into the VPS after it is created and run:
docker run --rm -it \
-v /home/deploy/docker/protonbridge/data:/root \
shenxn/protonmail-bridge:3.19.0-1 initFollow the interactive prompts to log in with your Proton account credentials. After a successful login, the bridge will output the SMTP credentials (host, port, username, password) when you prompt info needed to configure Paperless email consumption.
Reference: protonmail-bridge-docker
Copy the example below to infrastructure/terraform.tfvars and fill in your values:
# Passphrase used to encrypt the OpenTofu state file at rest
encryption_passphrase = "..."
# Hetzner Cloud API token
hcloud_token = "..."
# Tailscale auth key
tailscale_key = "tskey-auth-..."
# VPS and storage box configuration (defaults shown)
vps = { type = "cx33", location = "fsn1" }
storage = { type = "bx11", location = "fsn1", mount_path = "/mnt/storage" }
# Public hostname — subdomains paperless.*, traefik.*, paperless-ai.* will be created
hostname = "papercloud.example.com"
# Admin credentials for Paperless and the Traefik dashboard
admin_email = "you@example.com"
admin_username = "admin"
admin_password = "..."
# Basic auth hash for the Traefik dashboard (see below for generation)
admin_basic_auth = "admin:$$2a$$12$$..."
# Bunny CDN backup storage
bunnycdn = {
storage_zone = "my-paperless-backup" # storage zone name
api_key = "..." # storage zone password (not the account API key)
path = "backups"
}
# Number of backups to retain before purging older ones
backup_retention_count = 7echo $(htpasswd -nbB USER "PASSWORD") | sed -e s/\\$/\\$\\$/gReplace USER and PASSWORD with your desired credentials. The output goes directly into admin_basic_auth.
mise run deployBackups run nightly at 02:00 UTC via cron and are uploaded to Bunny CDN, retaining the last
backup_retention_countarchives. Make sure to check if the backups are working correctly instead of trusting this implementation blindly.
