feat(send): add role for self-hosted Send file-share service

Deploys timvisee/send with a Redis backend behind Traefik. Supports
local-disk or S3 storage (e.g. via the garage role). Uses the shared
`*_domains` list convention so the router can accept internal *.int.*
names alongside the canonical BASE_URL host.
This commit is contained in:
Simon Bärlocher 2026-05-20 22:00:32 +02:00
parent 9a9039c4d3
commit 4655c8f037
No known key found for this signature in database
GPG key ID: 63DE20495932047A
7 changed files with 236 additions and 0 deletions

60
roles/send/README.md Normal file
View file

@ -0,0 +1,60 @@
Send
====
Deploys a self-hosted [Send](https://github.com/timvisee/send) instance
(timvisee fork of the discontinued Mozilla Send) with a Redis backend
behind Traefik, using Docker Compose.
Requirements
------------
- Docker + `docker compose` plugin on the target host
- Traefik (role `digitalboard.core.traefik`) reachable via an external
Docker network named `proxy` (default)
- DNS for each entry in `send_domains` pointing at the reverse proxy
- Optional: a Garage S3 bucket if `send_storage_backend: s3`
Role Variables
--------------
Important defaults (see `defaults/main.yml` for the full list):
| Variable | Default | Description |
|---|---|---|
| `send_domains` | `["send.local.test"]` | FQDNs the router accepts; first entry is the canonical BASE_URL |
| `send_image` | `registry.gitlab.com/timvisee/send:latest` | Send container image |
| `send_max_file_size` | `1073741824` | Max upload size in bytes (1 GiB) |
| `send_max_expire_seconds` | `604800` | Max share lifetime (7 d) |
| `send_storage_backend` | `local` | `local` (volume) or `s3` |
| `send_s3_*` | `""` | S3 endpoint/bucket/key/secret (when backend is `s3`) |
| `send_use_ssl` | `true` | Issue Traefik labels for the `websecure` entrypoint |
Dependencies
------------
None.
Example Playbook
----------------
```yaml
- hosts: send_servers
become: true
roles:
- digitalboard.core.send
```
With S3 (Garage) backend:
```yaml
send_storage_backend: s3
send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domain'] }}"
send_s3_bucket: "send"
send_s3_access_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['key_id'] }}"
send_s3_secret_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['secret_key'] }}"
```
License
-------
MIT

View file

@ -0,0 +1,53 @@
#SPDX-License-Identifier: MIT-0
---
# defaults file for send
# Base directory configuration (inherited from base role or defined here)
docker_compose_base_dir: /etc/docker/compose
docker_volume_base_dir: /srv/data
# Send-specific configuration
send_service_name: send
send_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ send_service_name }}"
send_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ send_service_name }}"
# Service configuration
# FQDNs the send router accepts. The first entry is the canonical
# domain (used as BASE_URL); further entries cover internal *.int.*
# names so backend uploads can hit us without hairpinning via DMZ.
send_domains:
- "send.local.test"
send_image: "registry.gitlab.com/timvisee/send:latest"
send_port: 1443
send_extra_hosts: []
# Redis backend
send_redis_image: "redis:7-alpine"
send_redis_service_name: "send-redis"
# Send application configuration
# https://github.com/timvisee/send/blob/master/server/config.js
send_max_file_size: 1073741824 # 1 GiB in bytes
send_default_downloads: 1
send_max_downloads: 100
send_default_expire_seconds: 86400 # 24h
send_max_expire_seconds: 604800 # 7d
send_max_files_per_archive: 64
send_download_counts: "1,2,3,4,5,20,50,100"
send_expire_times_seconds: "300,3600,86400,604800"
# Storage backend: "local" (volume) or "s3"
send_storage_backend: "local"
# S3 backend (only used when send_storage_backend == "s3")
send_s3_endpoint: ""
send_s3_bucket: ""
send_s3_region: "us-east-1"
send_s3_access_key: ""
send_s3_secret_key: ""
send_s3_use_path_style: true
# Traefik configuration
send_traefik_network: "proxy"
send_internal_network: "send_internal"
send_use_ssl: true

View file

@ -0,0 +1,9 @@
#SPDX-License-Identifier: MIT-0
---
# handlers file for send
- name: restart send
community.docker.docker_compose_v2:
project_src: "{{ send_docker_compose_dir }}"
state: present
recreate: always

14
roles/send/meta/main.yml Normal file
View file

@ -0,0 +1,14 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: digitalboard
description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose
license: MIT
min_ansible_version: 2.14
galaxy_tags:
- send
- filesharing
- docker
dependencies: []

28
roles/send/tasks/main.yml Normal file
View file

@ -0,0 +1,28 @@
#SPDX-License-Identifier: MIT-0
---
# tasks file for send
- name: Create docker compose directory
file:
path: "{{ send_docker_compose_dir }}"
state: directory
mode: '0755'
- name: Create local upload directory
file:
path: "{{ send_docker_volume_dir }}/uploads"
state: directory
mode: '0755'
when: send_storage_backend == "local"
- name: Create docker-compose file for send
template:
src: docker-compose.yml.j2
dest: "{{ send_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
notify: restart send
- name: Start send container
community.docker.docker_compose_v2:
project_src: "{{ send_docker_compose_dir }}"
state: present

View file

@ -0,0 +1,69 @@
services:
{{ send_service_name }}:
image: {{ send_image }}
container_name: {{ send_service_name }}
restart: unless-stopped
depends_on:
- {{ send_redis_service_name }}
networks:
- {{ send_traefik_network }}
- {{ send_internal_network }}
{% if send_extra_hosts is defined and send_extra_hosts | length > 0 %}
extra_hosts:
{% for host in send_extra_hosts %}
- "{{ host }}"
{% endfor %}
{% endif %}
environment:
{% if send_use_ssl %}
BASE_URL: "https://{{ send_domains[0] }}"
{% else %}
BASE_URL: "http://{{ send_domains[0] }}"
{% endif %}
REDIS_HOST: "{{ send_redis_service_name }}"
REDIS_PORT: "6379"
MAX_FILE_SIZE: "{{ send_max_file_size }}"
DEFAULT_DOWNLOADS: "{{ send_default_downloads }}"
MAX_DOWNLOADS: "{{ send_max_downloads }}"
DEFAULT_EXPIRE_SECONDS: "{{ send_default_expire_seconds }}"
MAX_EXPIRE_SECONDS: "{{ send_max_expire_seconds }}"
MAX_FILES_PER_ARCHIVE: "{{ send_max_files_per_archive }}"
DOWNLOAD_COUNTS: "{{ send_download_counts }}"
EXPIRE_TIMES_SECONDS: "{{ send_expire_times_seconds }}"
{% if send_storage_backend == "s3" %}
S3_BUCKET: "{{ send_s3_bucket }}"
S3_ENDPOINT: "{{ send_s3_endpoint }}"
S3_USE_PATH_STYLE_ENDPOINT: "{{ 'true' if send_s3_use_path_style else 'false' }}"
AWS_ACCESSKEYID: "{{ send_s3_access_key }}"
AWS_SECRETACCESSKEY: "{{ send_s3_secret_key }}"
AWS_REGION: "{{ send_s3_region }}"
{% else %}
FILE_DIR: "/uploads"
volumes:
- {{ send_docker_volume_dir }}/uploads:/uploads
{% endif %}
labels:
- traefik.enable=true
- traefik.docker.network={{ send_traefik_network }}
- traefik.http.routers.{{ send_service_name }}.rule=Host({% for d in send_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %})
- traefik.http.services.{{ send_service_name }}.loadbalancer.server.port={{ send_port }}
{% if send_use_ssl %}
- traefik.http.routers.{{ send_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ send_service_name }}.tls=true
{% else %}
- traefik.http.routers.{{ send_service_name }}.entrypoints=web
{% endif %}
{{ send_redis_service_name }}:
image: {{ send_redis_image }}
container_name: {{ send_redis_service_name }}
restart: unless-stopped
networks:
- {{ send_internal_network }}
volumes:
- {{ send_docker_volume_dir }}/redis:/data
networks:
{{ send_internal_network }}:
{{ send_traefik_network }}:
external: true

3
roles/send/vars/main.yml Normal file
View file

@ -0,0 +1,3 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for send