The Architecture Rationale
Modern application frameworks (like Laravel) are natively designed to leverage AWS S3 or Google Cloud Storage for asset management, relying on API-driven file operations and temporary signed URLs. However, inside a self-hosted Proxmox ecosystem, deploying full S3-compatible object storage engines like GarageFS or RustFS can introduce unnecessary complexity, bloated resource footprints, and buggy administrative GUIs.
To maintain a lean, high-performance, and secure asset backend, I bypassed S3 emulators entirely and engineered a dedicated Alpine Linux LXC running a hardened SFTP Server coupled with FileBrowser. This provides direct, key-authenticated filesystem access to my applications (like Laravel) while offering a beautiful web interface for manual file management.
Phase 1: The Decision Matrix
My evaluation of storage backends highlights the balance between API features and operational overhead:
- AWS S3 & Google Cloud Storage: Excellent for cloud-native setups, but introduces external dependencies, variable egress costs, and compliance overhead.
- GarageFS & RustFS: Evaluated and discarded. While capable of generating S3-style temporary URLs, the GUIs were buggy, and the overall setup was heavily over-engineered for isolated LAN-based applications.
- Hardened SFTP (OpenSSH): Selected. Extremely lightweight, universally supported by frameworks via standard drivers (e.g., Flysystem SFTP), and easily secured using SSH key pairs and chroot environments.
Phase 2: Automated SFTP Provisioning
To enforce absolute isolation, every application or client connecting to the storage layer gets a dedicated, unprivileged system user constrained to a locked chroot directory.
I wrote an automated provisioning script to handle the configuration lifecycle:
Bash
# Deployed in an Alpine Linux LXC for a minimal, fast-booting OS footprint
nano ./create-sftp-user.sh
chmod +x create-sftp-user.sh
The Provisioning Engine
This script installs OpenSSH, creates a restricted user group, configures the chroot jail (/home/username/data), registers the client’s public SSH key, and hardens the daemon configuration:
Bash
#!/bin/sh
# Interactive/Argument-driven SFTP Provisioner
USER_NAME="$1"
USER_PASSWORD="$2"
PUB_KEY="$3"
# [Omitted interactive read prompts for brevity - Script automates the following steps:]
# 1. Install openssh-server & openssh
# 2. Add system group 'sftpusers'
# 3. Create user, set shell to /sbin/nologin, and assign password
# 4. Enforce strict chroot permissions (root ownership on home, 755 permission)
# 5. Mount a secure writeable subdirectory: /home/$USER_NAME/data (owned by the user)
# 6. Inject SSH Public Key into authorized_keys
# 7. Append hardening rules to /etc/ssh/sshd_config:
# Match Group sftpusers
# ChrootDirectory /home/%u
# ForceCommand internal-sftp -d /data
# PasswordAuthentication no
# PubkeyAuthentication yes
# X11Forwarding no
# AllowTcpForwarding no
rc-service sshd restart
Phase 3: Visual File Management (FileBrowser)
While applications interact via SFTP, I integrated FileBrowser inside the LXC to provide a sleek web GUI for administrators to upload and manage assets.
Mitigating Permission Drift
When files are uploaded through FileBrowser’s web UI, they are written as the service’s default admin user (root). This causes permission mismatches when the restricted SFTP user attempts to read or write to those files via an application.
To resolve this, I implement a permission correction routine:
Bash
# Force correct ownership back to the application user
chown -R app_user:app_user /home/app_user/data
# Enforce standard, secure directory and file permissions
find /home/app_user/data -type d -exec chmod 755 {} \;
find /home/app_user/data -type f -exec chmod 644 {} \;
Phase 4: Laravel Driver Integration
By adopting SFTP, Laravel integrates directly using the standard league/flysystem-sftp-v3 adapter.
Resolving the “Temporary URL” Challenge
One major benefit of S3-based systems is the ability to generate temporary, time-expiring URLs via Storage::temporaryUrl(). In an SFTP architecture, we handle this securely on the application level rather than delegating it to the storage container.
In Laravel, I define a custom route that acts as a secure proxy controller. When a temporary URL is requested, Laravel generates a Signed Route pointing to our application. When hit, the controller verifies the cryptographic signature, pulls the file stream from the SFTP disk, and serves it directly to the user—ensuring secure, time-limited access without exposing the underlying storage nodes to the public internet.
