π Encrypted media architecture using gocryptfs π¬ Jellyfin-based watch parties with SyncPlay βοΈ Automated Google Cloud deployment + Docker stack
A self-hosted private movie theatre built on Jellyfin. This project provides encrypted media storage using gocryptfs and is designed for deployment on Google Cloud VMs with automated provisioning.
- Architecture
- Project Structure
- Quick Start
- VM Deployment
- Encrypted Media Workflow
- Uploading Media
- Accessing Jellyfin
- Using SyncPlay for Watch Parties
- Future Automation Notes
- License
The Theatre project is built around a layered architecture designed for security, privacy, and ease of deployment:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Users / Clients β
β (Web Browser, Mobile Apps) β
βββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ
β HTTPS (443)
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Google Cloud VM (Debian 12) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Docker Containers β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β Caddy Reverse Proxy β β β
β β β (Automatic HTTPS via DuckDNS DNS-01) β β β
β β ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ β β
β β β HTTP (8096) β β
β β βΌ β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β Jellyfin Media Server β β β
β β β (Streaming, Transcoding, SyncPlay) β β β
β β ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ β β
β βββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ β
β β Read-only mount β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Decrypted Mount (/srv/library_clear) β β
β β (gocryptfs FUSE mount) β β
β ββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ β
β β Transparent encryption β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Encrypted Storage (/mnt/disks/media/.library_encrypted)β β
β β (AES-256-GCM encrypted files) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Systemd Services β β
β β β’ gocryptfs-mount.service (auto-mount on boot) β β
β β β’ duckdns-update.timer (dynamic DNS every 5 min) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Component | Purpose |
|---|---|
| Jellyfin | Open-source media server for streaming video content with native SyncPlay support |
| Caddy | Reverse proxy with automatic HTTPS via Let's Encrypt and DuckDNS DNS-01 challenge |
| gocryptfs | FUSE-based encryption layer providing transparent encryption/decryption of media files |
| Docker | Container runtime for running Jellyfin and Caddy in isolated, reproducible environments |
| Cloud-Init | Automated VM provisioning on first boot (installs dependencies, clones repo, runs bootstrap) |
| DuckDNS | Free dynamic DNS service for accessing the server via a stable hostname |
| Systemd | Service management for automatic gocryptfs mounting and DuckDNS updates |
- Encryption in Transit: All traffic is encrypted via HTTPS using automatic TLS certificates from Let's Encrypt
- Encryption at Rest: All media files are encrypted using gocryptfs with AES-256-GCM
- Runtime Decryption: Files are decrypted on-the-fly only when accessed, never stored unencrypted on disk
- Password Protection: The gocryptfs password is required to mount the filesystem
- Read-Only Access: Jellyfin mounts the media directory as read-only to prevent accidental modifications
theatre/
βββ config/
β βββ .env.example # Environment variables template
β βββ caddy/ # Caddy reverse proxy configuration
β βββ Caddyfile # Caddy configuration file
β βββ Dockerfile # Custom Caddy build with DuckDNS module
βββ docs/
β βββ SETUP.md # Detailed encrypted storage setup guide
β βββ GOCRYPTFS-SERVICE.md # Systemd service configuration
β βββ DUCKDNS.md # Dynamic DNS setup guide
β βββ DEPLOY-SCRIPTS.md # Deployment scripts documentation
β βββ ENVIRONMENT-VARIABLES.md # Environment variable naming conventions
βββ infrastructure/
β βββ bootstrap.sh # VM bootstrap script
β βββ cloud-init.yaml # Cloud-init configuration for automated provisioning
β βββ scripts/ # Modular provisioning scripts
β βββ systemd/
β βββ gocryptfs-mount.service # Auto-mount encrypted storage on boot
β βββ duckdns-update.service # DuckDNS update service
β βββ duckdns-update.timer # Timer for periodic DNS updates
βββ media/
β βββ encrypted/ # Local gocryptfs encrypted storage
β βββ decrypted/ # Local gocryptfs mount point (gitignored)
βββ scripts/
β βββ upload-media.sh # Upload media files to remote VM
β βββ update-duckdns.sh # Manual DuckDNS update script
βββ docker-compose.yml # Docker services configuration
βββ README.md
Note: On deployed VMs, Jellyfin configuration is stored at /mnt/disks/media/jellyfin_config on the attached media disk to avoid disk space issues on the root volume.
- Docker and Docker Compose
- gocryptfs (optional, for encrypted media)
- Media files accessible at
/srv/library_clear(or configure your own path)
-
Configure environment (optional):
cp config/.env.example .env # Edit .env with your settings -
Ensure media directory exists:
# By default, media is expected at /srv/library_clear # You can modify the volume mount in docker-compose.yml if needed sudo mkdir -p /srv/library_clear
-
Start Jellyfin:
docker compose up -d
-
Access Jellyfin:
- Local HTTP: http://localhost:8096
- HTTPS (requires DuckDNS setup): https://yourdomain.duckdns.org
docker compose down
# If using encrypted storage:
fusermount -u media/decryptedThe Theatre project supports automated deployment to Google Cloud VMs using cloud-init and GitHub Actions.
- A Google Cloud Platform account with billing enabled
- A GCP project with Compute Engine API enabled
- GitHub repository secrets configured:
GCP_PROJECT_ID: Your Google Cloud project IDGCP_PROJECT_NUMBER: Your Google Cloud project numberGCP_ZONE: The zone for your VM (e.g.,us-central1-a)GCP_SA_EMAIL: Service account email with Compute Engine permissionsGOCRYPTFS_PASSWORD: Password for encrypting media filesDUCKDNS_TOKEN: Your DuckDNS authentication token
- GitHub repository variables configured:
GCP_VM_NAME: VM name (defaults totheatre-vm)GCP_MEDIA_DISK_NAME: Media disk name (defaults totheatre-media-disk)DOMAIN_NAME: Your DuckDNS domain (e.g.,movietheatre.duckdns.org)DUCKDNS_DOMAIN: Your DuckDNS subdomain (e.g.,movietheatre)
- Workload Identity Federation configured between GitHub and GCP
The Deploy Full Stack workflow provides a complete, idempotent deployment of the entire theatre platform:
-
Configure repository secrets and variables in GitHub:
- Go to Settings β Secrets and variables β Actions
- Add all required secrets and variables listed above
-
Trigger the deployment:
- Go to Actions β Deploy Full Stack
- Click Run workflow
The workflow will:
- Create or reuse the VM (idempotent)
- Create or reuse the media disk (idempotent)
- Attach the media disk to the VM
- Install fuse3, gocryptfs, Docker, and docker-compose
- Format the media disk (only if unformatted)
- Mount the disk at
/mnt/disks/media - Initialize gocryptfs encryption (only if not initialized)
- Mount the decrypted view at
/srv/library_clear - Install and enable systemd services for gocryptfs and DuckDNS
- Write
.envfile withDOMAIN_NAME,DUCKDNS_TOKEN, andJELLYFIN_URLfor Caddy and Jellyfin - Start Jellyfin and Caddy containers
- Obtain HTTPS certificates via Let's Encrypt
- Verify all components and print a deployment summary
Note: The .env file at /opt/theatre/repo/.env is automatically generated during deployment from GitHub secrets and variables. This file provides Caddy with the DuckDNS token required for automatic HTTPS certificate provisioning.
After successful deployment, access your theatre at:
https://${DOMAIN_NAME}
Note: The system will be fully deployed but with no movies. See Uploading Media for the next step.
For basic VM creation without full provisioning:
-
Configure repository secrets in GitHub:
- Go to Settings β Secrets and variables β Actions
- Add the required secrets listed above
-
Trigger the deployment:
- Go to Actions β Deploy VM
- Click Run workflow
The workflow will:
- Create a new VM or update an existing one
- Use cloud-init to automatically provision the VM
- Install Docker, gocryptfs, and required dependencies
- Clone the repository and run the bootstrap script
If you prefer to deploy manually or to a different cloud provider:
-
Create a VM with Debian 12 (or Ubuntu 22.04+)
-
SSH into the VM and clone the repository:
git clone https://github.com/szmyty/theatre.git /opt/theatre/repo
-
Run the bootstrap script:
sudo /opt/theatre/repo/infrastructure/bootstrap.sh
-
Initialize gocryptfs (first time only):
sudo gocryptfs --init /mnt/disks/media/.library_encrypted
-
Create the password file:
echo 'your-secure-password' | sudo tee /etc/gocryptfs/passfile > /dev/null sudo chmod 600 /etc/gocryptfs/passfile
-
Start the gocryptfs mount service:
sudo systemctl start gocryptfs-mount
After deployment, complete these steps:
-
Configure DuckDNS and HTTPS:
- Follow the instructions in docs/DUCKDNS.md
- Set
DOMAIN_NAMEandDUCKDNS_TOKENin your.envfile - Caddy will automatically obtain TLS certificates
-
Set up firewall rules to allow traffic on ports:
- Port 80 (HTTP - for ACME challenges)
- Port 443 (HTTPS)
- Port 8096 (optional, for direct HTTP access to Jellyfin)
-
Complete Jellyfin initial setup by accessing the web interface
The Theatre project uses gocryptfs for transparent encryption of all media files. This ensures data is encrypted at rest while remaining accessible to Jellyfin.
- Encrypted Storage: All media files are stored encrypted in
/mnt/disks/media/.library_encrypted(VM) ormedia/encrypted(local) - Decrypted Mount: gocryptfs mounts the encrypted directory to
/srv/library_clear(VM) ormedia/decrypted(local) - Transparent Access: Files are decrypted on-the-fly when read and encrypted when written
- Jellyfin Access: Jellyfin reads media from the decrypted mount point
-
Create directories:
sudo mkdir -p /mnt/disks/media/.library_encrypted sudo mkdir -p /srv/library_clear
-
Initialize gocryptfs:
gocryptfs --init /mnt/disks/media/.library_encrypted
You'll be prompted to create a password. Store this password securely β it's required to mount the encrypted filesystem.
-
Mount the encrypted filesystem:
gocryptfs /mnt/disks/media/.library_encrypted /srv/library_clear
For production deployments, use the systemd service for automatic mounting:
-
Configure FUSE to allow Docker access:
echo "user_allow_other" | sudo tee -a /etc/fuse.conf
-
Create the password file:
sudo mkdir -p /etc/gocryptfs echo 'your-password' | sudo tee /etc/gocryptfs/passfile > /dev/null sudo chmod 600 /etc/gocryptfs/passfile
-
Create the environment file at
/etc/gocryptfs/gocryptfs.env:GOCRYPTFS_ENCRYPTED_DIR=/mnt/disks/media/.library_encrypted GOCRYPTFS_MOUNT_POINT=/srv/library_clear GOCRYPTFS_PASSFILE=/etc/gocryptfs/passfile
-
Install and enable the service:
sudo cp infrastructure/systemd/gocryptfs-mount.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now gocryptfs-mount
To unmount the encrypted filesystem:
fusermount -u /srv/library_clearOr using systemd:
sudo systemctl stop gocryptfs-mount- Backup your password: Without the password, encrypted data cannot be recovered
- Backup gocryptfs.conf: This file in the encrypted directory contains the encrypted master key
- Password file security: Ensure
/etc/gocryptfs/passfilehas mode 600 and is owned by root
For detailed setup instructions, see docs/SETUP.md and docs/GOCRYPTFS-SERVICE.md.
After running the Deploy Full Stack workflow, the system is fully deployed but has no movies yet. This is intentional β uploading media is the next manual step.
The project includes a script for uploading media files to the remote VM. When you upload files to /srv/library_clear, gocryptfs automatically encrypts them on write, and Jellyfin immediately sees them.
./scripts/upload-media.sh <local_file> <remote_host>Arguments:
local_file: Path to the local video file to uploadremote_host: Remote host (e.g.,user@hostnameoruser@IP)
Examples:
# Upload a movie file using your DuckDNS domain
./scripts/upload-media.sh /path/to/movie.mp4 user@movietheatre.duckdns.org
# Upload a movie file using the VM's IP address
./scripts/upload-media.sh /path/to/movie.mp4 user@192.168.1.100
# Upload to a VM with a hostname
./scripts/upload-media.sh movie.mkv ubuntu@theatre.duckdns.orgThe script will:
- Validate the file exists and is readable
- Use SCP with compression to upload the file
- Copy the file to
/srv/library_clearon the remote host
You can also upload files manually using SCP:
scp -C /path/to/video.mp4 user@remote-host:/srv/library_clear/After uploading new media:
-
Refresh Jellyfin library:
- Go to Dashboard β Libraries
- Click Scan All Libraries
-
Or wait for automatic scan: Jellyfin periodically scans for new media (configurable in settings)
- HTTP: http://localhost:8096
The Theatre project includes Caddy reverse proxy with automatic HTTPS via Let's Encrypt and DuckDNS.
- A DuckDNS account and subdomain (e.g.,
movietheatre.duckdns.org) - Your DuckDNS authentication token
-
Set up environment variables in your
.envfile:cp config/.env.example .env # Edit .env with your values: DOMAIN_NAME=movietheatre.duckdns.org DUCKDNS_TOKEN=your-duckdns-token-here -
Set up DuckDNS dynamic DNS following docs/DUCKDNS.md
-
Configure firewall rules to allow traffic on ports:
- Port 80 (HTTP - for automatic HTTP to HTTPS redirects)
- Port 443 (HTTPS)
-
Start the services:
docker compose up -d
-
Access Jellyfin via HTTPS:
- HTTPS: https://movietheatre.duckdns.org (or your configured domain)
Caddy will automatically:
- Obtain TLS certificates from Let's Encrypt using DNS-01 challenge
- Redirect HTTP to HTTPS
- Renew certificates before they expire
- Store certificates in the
caddy_datavolume
If you prefer HTTP-only access:
- Set up DuckDNS following docs/DUCKDNS.md
- Access via: http://yoursubdomain.duckdns.org:8096
Access via the VM's public IP: http://YOUR_VM_IP:8096
- Access the web interface at https://your-domain.duckdns.org (or http://localhost:8096 for local access)
- Create an admin account with a strong password
- Add media libraries:
- Click Add Media Library
- Select content type (Movies, Shows, etc.)
- Add folder:
/media - Configure metadata providers as desired
- Complete the setup wizard
Jellyfin has official apps for:
- iOS (App Store)
- Android (Google Play, F-Droid)
- Android TV
- Amazon Fire TV
- Roku
Configure the app with your server address (e.g., https://yoursubdomain.duckdns.org).
SyncPlay is Jellyfin's built-in feature for synchronized playback across multiple users, perfect for hosting virtual watch parties.
- One user creates a SyncPlay group
- Other users join the group
- Playback is synchronized across all group members
- Play, pause, and seek actions are shared in real-time
- Start playing the movie or show you want to watch
- Open the playback menu (click the screen or press the menu button)
- Click the SyncPlay icon (two overlapping circles)
- Select "Create Group"
- Share the group name with your friends
- Navigate to the same media the host is playing
- Start playback
- Open the playback menu
- Click the SyncPlay icon
- Select "Join Group" and choose the group name
- Same media required: All participants must have access to the same media file
- Buffer time: SyncPlay accounts for network latency and buffering differences
- Host controls: Any group member can control playback (play, pause, seek)
- Chat: Use an external chat app for communication during the watch party
- Permissions: Ensure all participants have permission to access the media library
For the best SyncPlay experience:
-
Enable transcoding if participants have different bandwidth capabilities:
- Dashboard β Playback β Enable transcoding
-
Set appropriate quality:
- Each user can set their preferred quality in playback settings
-
Stable connection: Recommend all participants use a stable internet connection
The Theatre project includes a comprehensive backup strategy for disaster recovery. See docs/BACKUP.md for full documentation.
| Component | Method | Frequency |
|---|---|---|
| Media Disk | GCP Disk Snapshots | Daily at 4:00 AM UTC |
| Jellyfin Config | GCS Sync | Daily at 3:00 AM UTC |
| gocryptfs.conf | GCS Sync | Daily at 3:00 AM UTC |
- Create GCS bucket for configuration backups
- Add
BACKUP_BUCKETvariable to GitHub repository - Create snapshot schedule using
infrastructure/scripts/ensure_snapshot_schedule.sh - Enable VM-side backups by running
infrastructure/scripts/ensure_backup.shon the VM
Via GitHub Actions:
- Go to Actions β Backup
- Click Run workflow
Via SSH:
sudo /opt/theatre/repo/scripts/backup-to-gcs.sh --bucket gs://your-backup-bucketThe following automation improvements are planned or in progress:
- Terraform Infrastructure: Infrastructure as Code for full GCP deployment automation
- Monitoring & Alerting: Integration with Prometheus/Grafana for system monitoring
- Multi-region Deployment: Support for deploying to multiple regions for better latency
- GitHub Actions Improvements:
- Deployment status notifications
- Automated gocryptfs password management with Secret Manager
| Feature | Status | Description |
|---|---|---|
| VM Provisioning | β Complete | Cloud-init automated setup |
| Docker Deployment | β Complete | Docker Compose configuration |
| gocryptfs Auto-mount | β Complete | Systemd service for boot-time mounting |
| DuckDNS Updates | β Complete | Systemd timer for periodic DNS updates |
| GitHub Actions Deploy | β Complete | One-click VM deployment workflow |
| Automatic HTTPS | β Complete | Caddy reverse proxy with Let's Encrypt via DuckDNS DNS-01 |
| Automated Backups | β Complete | GCS sync and GCP disk snapshots with GitHub Actions workflow |
| VM Schedule | β Complete | Automated VM shutdown/startup to reduce GCP costs |
The VM Schedule workflow automatically manages the VM power state to reduce GCP costs during inactive periods.
| Time | Action | Purpose |
|---|---|---|
| 07:00 | Start VM | Begin active hours |
| 23:00 | Stop VM | End active hours, reduce costs |
You can also manually start, stop, or check the VM status:
- Go to Actions β VM Schedule
- Click Run workflow
- Select the action:
start- Start the VMstop- Stop the VMstatus- Check current VM status
With the default schedule, the VM runs for 16 hours per day instead of 24 hours, reducing compute costs by approximately 33%. Stopped VMs only incur storage costs for attached disks.
Note: When the VM is stopped:
- The theatre will be inaccessible
- No charges for compute resources
- Persistent disk storage charges still apply
- DuckDNS will point to the last known IP (may become stale)
Contributions to automation improvements are welcome! Check the GitHub Issues for current tasks or open a new issue to propose enhancements.
MIT License - see LICENSE for details.