Building an All-in-One Home Server on Raspberry Pi 5

I am writing this guide for my future self, for the next time he inadvertently destroys the Raspberry Pi’s SD card.!
This is a simple deep-dive on turning a Raspberry Pi 5 into a secure home server. In this guide, we will transform a raw micro-SD card into a self-hosted infrastructure featuring network-wide ad-blocking (AdGuard Home), private VPN access (WireGuard), reverse proxy routing (Nginx Proxy Manager), root-level file management (Filebrowser), centralized Docker administration (Portainer), automated encrypted cloud backups (Kopia + Rclone), and a clean unified dashboard (Homepage).

Table of Contents

1. Hardware & Prerequisites

Before starting, ensure you have the following hardware:

  • Single Board Computer: Raspberry Pi 5 (8GB RAM recommended).
  • Storage: High-speed A2-rated MicroSD card (64GB+) or NVMe M.2 SSD with PCIe HAT.
  • Power Supply: Official 27W USB-C Power Supply.
  • Network Connection: Ethernet cable connected directly to your primary router.

2. MicroSD Preparation with Raspberry Pi Imager

We will flash Raspberry Pi OS (64-bit) using the official Raspberry Pi Imager to pre-configure SSH and user credentials headlessly.

  1. Download & Install: Download Raspberry Pi Imager from the official website.
  2. Select Device & OS:
  • Device: Raspberry Pi 5
  • OS: Raspberry Pi OS (64-bit) (Debian Bookworm)
  • Storage: Select your inserted MicroSD card.
  1. Configure OS Customization Settings:
  • Click EDIT SETTINGS:
  • General Tab:
    • Set hostname: Raspberry-PI5
    • Set username: kenshiro
    • Set password: (Enter a secure password)
    • Configure Wireless LAN (optional, Ethernet preferred).
    • Set Locale Settings (Time zone and Keyboard layout).
  • Services Tab:
    • Check Enable SSH → Select Use password authentication.
  • Click SAVE and proceed to flash the MicroSD card.

3. Initial System Setup & Network Discovery

Insert the MicroSD card into your Raspberry Pi 5, attach the Ethernet cable, and power it on.

Step 3.1: Locating Your Raspberry Pi’s Local IP Address

Before establishing the first SSH connection, locate the local IPv4 address assigned to your Raspberry Pi by your router’s DHCP server:

  • Method 1: Router Admin Interface (Recommended):
    Log in to your router’s web interface (e.g., http://192.168.1.1). Navigate to Connected Devices or DHCP Client List and locate the device named Raspberry-PI5.
  • Method 2: Hostname Resolution (mDNS):
    On macOS, Linux, or Windows (with Bonjour/mDNS enabled), open your terminal and ping the hostname directly:
ping Raspberry-PI5.local
  • Method 3: Network Scanning:
    Use a mobile network scanner app (e.g., Fing) or run a command-line utility on your local network:
arp -a | grep -i "Raspberry"

Step 3.2: Connect via SSH

Once you have retrieved the IP address (e.g., 192.168.1.118), connect via SSH using the username kenshiro:

ssh kenshiro@192.168.1.118

Or using mDNS hostname

ssh kenshiro@Raspberry-PI5.local

Step 3.3: System Update & Directory Workspace

Perform a full upgrade of Debian packages and establish a clean /opt workspace structure:

# Update system packages
sudo apt update && sudo apt full-upgrade -y
sudo apt autoremove -y

# Create stack and application volume directories
sudo mkdir -p /opt/stacks/{adguard,wireguard,nginxproxymanager,portainer,filebrowser,homepage}
sudo mkdir -p /opt/{adguard/conf,adguard/work,filebrowser,portainer/data}
sudo chown -R kenshiro:kenshiro /opt/stacks /opt/adguard /opt/filebrowser /opt/portainer

4. Docker & Docker Compose Installation

Install Docker Engine using the official installation script:

# Download and run official Docker installer
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# Add user 'kenshiro' to docker group to run containers without sudo
sudo usermod -aG docker kenshiro

# Enable and start Docker service
sudo systemctl enable --now docker

# Remove installation script
rm get-docker.sh

Verification: Log out and reconnect via SSH, then verify Docker and Compose:

docker --version
docker compose version

5. Deploying AdGuard Home (DNS & Ad-Blocking)

AdGuard Home is deployed first so that local DNS filtering and custom domain rewrites are active before delegating router DNS traffic.

Step 5.1: Create Docker Compose Configuration

Create /opt/stacks/adguard/docker-compose.yml:

services:
  adguardhome:
    image: adguard/adguardhome:latest
    container_name: adguardhome
    restart: unless-stopped
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "8080:8080/tcp" # Web UI mapped to 8080 (frees Port 3000 for Homepage)
    volumes:
      - /opt/adguard/work:/opt/adguardhome/work
      - /opt/adguard/conf:/opt/adguardhome/conf

Step 5.2: Start AdGuard Home

cd /opt/stacks/adguard && docker compose up -d

Step 5.3: Web Setup & DNS Rewrites

  • Navigate to http://192.168.1.118:8080 in your web browser.
  • Complete the setup wizard (set admin web port to 8080).
  • Configure DNS Rewrites:
    Go to Filters -> DNS Rewrites and add:
    • files.home → 192.168.1.118 *(Local subdomain routing, see at the end).

6. Router Configuration & Networking

With AdGuard Home active, configure your primary router (192.168.1.1) to delegate all DNS queries to the Raspberry Pi.

How to Find Your Raspberry Pi’s IPv6 Address

  • Method 1: Via Terminal (Recommended):
    Run the following command on the Raspberry Pi:
ip -6 addr show dev eth0

Output snippet:

inet6 2a02:1210:2033:2d00:8ba2:9efe:fe96:f29e/64 scope global dynamic
inet6 fe80::8ba2:9efe:fe96:f29e/64 scope link
  • Global Unicast Address (2a02:... or 2001:...): Public IPv6 assigned dynamically by your ISP.
  • Link-Local Address (fe80::...): Static local IPv6 address. Recommended for router setup because it never changes, even if your ISP updates your public IPv6 prefix.
  • Quick alternative: Run hostname -I to view all assigned IPs simultaneously.
  • Method 2: Via AdGuard Home Web UI:

Router Configuration Steps

  • Static IP Reservation:
    • Assign a fixed IPv4 address to your Raspberry Pi 5 MAC address (192.168.1.118).
  • Router DNS Delegation (IPv4 & IPv6):
    • Primary IPv4 DNS: Set to 192.168.1.118.
    • Primary IPv6 DNS (DHCPv6): Set “DNS Server IPv6 notified through DHCPv6” to your Raspberry Pi’s Link-Local address (fe80::8aa2:9efe:fe96:f29e).
    • Why IPv6 DNS is critical: Notifying AdGuard’s IPv6 address ensures all network devices resolve DNS via AdGuard regardless of connection protocol.
  • Port Forwarding (Remote Access via WireGuard):
    • WireGuard VPN: Forward External UDP Port 51820 → 192.168.1.118:51820.
    • Ports 80 & 443 (HTTP/HTTPS): Keep ports 80 and 443 closed to the Internet. Following a “VPN-First” security architecture, Nginx Proxy Manager handles reverse proxying internally. Remote access to web services is securely tunneled through WireGuard, keeping your server hidden from public port scanners.

7. Deploying Core Infrastructure Services

WireGuard & WireGuard-UI (Secure Remote Access)

Create /opt/stacks/wireguard/docker-compose.yml:

services:
  wireguard-ui:
    image: ngoduykhanh/wireguard-ui:latest
    container_name: wireguard-ui
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
    network_mode: host
    environment:
      - WGUI_SERVER_POST_UP_SCRIPT=iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
      - WGUI_SERVER_POST_DOWN_SCRIPT=iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
    volumes:
      - /opt/wireguard/db:/app/db
      - /etc/wireguard:/etc/wireguard

Start WireGuard-UI:

cd /opt/stacks/wireguard && docker compose up -d

Nginx Proxy Manager (Reverse Proxy)

Create /opt/stacks/nginxproxymanager/docker-compose.yml:

services:
  app:
    image: 'jc21/nginx-proxy-manager:latest'
    container_name: nginxproxymanager
    restart: unless-stopped
    ports:
      - '80:80'
      - '81:81'
      - '443:443'
    volumes:
      - /opt/nginxproxymanager/data:/data
      - /opt/nginxproxymanager/letsencrypt:/etc/letsencrypt

Start Nginx Proxy Manager:

cd /opt/stacks/nginxproxymanager && docker compose up -d

Portainer CE (Container Management)

Create /opt/stacks/portainer/docker-compose.yml:

services:
  portainer:
    image: portainer/portainer-ce:latest
    container_name: portainer
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    ports:
      - "9000:9000"
      - "9443:9443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /opt/portainer/data:/data

Start Portainer:

cd /opt/stacks/portainer && docker compose up -d

Filebrowser (Full Filesystem Scope)

To grant Filebrowser full access to host system directories (/opt, /etc, /home), run the container with root permissions (user: "0:0"), map root / to /srv, and explicitly specify the listening address --address 0.0.0.0.

Create /opt/stacks/filebrowser/docker-compose.yml:

services:
  filebrowser:
    image: filebrowser/filebrowser:latest
    container_name: filebrowser
    restart: unless-stopped
    user: "0:0"
    ports:
      - "8082:80"
    volumes:
      - /:/srv
      - /opt/filebrowser/filebrowser.db:/database/filebrowser.db
    command: --address 0.0.0.0 --port 80 --database /database/filebrowser.db --root /srv

Initialize the database file and start Filebrowser:

sudo touch /opt/filebrowser/filebrowser.db
cd /opt/stacks/filebrowser && docker compose up -d

Homepage (Unified Application Dashboard)

Create /opt/stacks/homepage/docker-compose.yml:

services:
  homepage:
    image: ghcr.io/gethomepage/homepage:latest
    container_name: homepage
    restart: unless-stopped
    ports:
      - "3000:3000"
    volumes:
      - /opt/homepage/config:/app/config
      - /var/run/docker.sock:/var/run/docker.sock:ro

Start Homepage:

cd /opt/stacks/homepage && docker compose up -d

8. Automated Offsite Backup Engine (Kopia + Rclone)

To back up host directories (/opt, /etc, /home/kenshiro) securely to Google Drive, we run an Rclone WebDAV bridge paired with Kopia Server.

Step 8.1: Configure Rclone Remote

rclone config
# Name: gdrive
# Storage: Google Drive
# Complete interactive OAuth token authorization flow

Step 8.2: Systemd Unit for Rclone WebDAV Server

Create /etc/systemd/system/rclone-kopia.service:

[Unit]
Description=Rclone WebDAV Bridge for Kopia Backup
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=kenshiro
ExecStart=/usr/bin/rclone serve webdav gdrive: --addr 127.0.0.1:8085 --config /home/kenshiro/.config/rclone/rclone.conf --vfs-cache-mode full
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now rclone-kopia

Step 8.3: Systemd Unit for Kopia Daemon

Create /etc/systemd/system/kopia-server.service:

[Unit]
Description=Kopia Backup Daemon
After=network-online.target rclone-kopia.service
Wants=rclone-kopia.service

[Service]
Type=simple
User=root
ExecStart=/usr/bin/kopia server start --insecure
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Enable and start Kopia Server:

sudo systemctl daemon-reload
sudo systemctl enable --now kopia-server

Step 8.4: Set Retention & Automated Backup Schedules

# Set weekly backup policy for target host directories
sudo kopia policy set /opt /etc /home/kenshiro --snapshot-interval=168h --keep-weekly=4 --keep-monthly=12

# Trigger initial manual backup snapshot
sudo kopia snapshot create /opt /etc /home/kenshiro

9. System Verification & Health Checks

Verify all endpoints and active service ports on your home server:

ServiceProtocol/AddressLocal Gateway PortPurpose
Nginxhttp://192.168.1.118:8181Reverse Proxy Router
AdGuardhttp://192.168.1.118:808053 / 8080DNS Filtering & Local Rewrites
Portainerhttp://192.168.1.118:90009000Docker Web Interface
Filebrowserhttp://192.168.1.118:80828082File Manager
Homepage Dashboardhttp://192.168.1.118:30003000Unified UI Portal
Rclonehttp://127.0.0.1:80858085Cloud Backup Transport

Check container health at any time using:

docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"

10. Local Domain Routing (AdGuard Home & Nginx Proxy Manager Integration)

To access your containerized services using friendly custom domain names (such as files.home or portainer.home) instead of remembering IP addresses and port numbers (192.168.1.118:8082), AdGuard Home and Nginx Proxy Manager work in tandem:

  1. AdGuard Home (DNS Level): Intercepts local domain requests (e.g., files.home) and resolves them to the Raspberry Pi’s local IP address (192.168.1.118).
  2. Nginx Proxy Manager (Application Level): Listens on HTTP port 80, inspects the incoming host header (files.home), and reverse-proxies the traffic to the designated container’s port (e.g., 8082).

Step 1: Create DNS Rewrites in AdGuard Home

  • Access the AdGuard Home web dashboard at http://192.168.1.118:8080.
  • Navigate to Filters → DNS Rewrites.
  • Click Add DNS rewrite and enter:
    • Domain Name: files.home (or a wildcard rule like *.home to route all local subdomains).
    • IP Address: 192.168.1.118
  • Click Save.

Step 2: Configure Proxy Hosts in Nginx Proxy Manager

  • Access Nginx Proxy Manager at http://192.168.1.118:81.
  • Log in using your administrator credentials.
  • Go to Hosts → Proxy Hosts and click Add Proxy Host.
  • In the Details tab, configure:
    • Domain Names: files.home (press Enter after typing)
    • Scheme: http
    • Forward Hostname / IP: 192.168.1.118
    • Forward Port: 8082 (Filebrowser host port)
    • Check Block Common Exploits.
    • Check Websockets Support (required for live file upload/download in Filebrowser).
  • Click Save.

Step 3: Verification

Open a new browser tab and navigate to http://files.home. You will be seamlessly routed to Filebrowser without entering port :8082.

Android: pipe(“|”) and ProcessBuilder

As of today there’s no public implementation of the java ProcessBuilder.startPipeline in Android.

So I came out with this quick solution, using Kotlin, to pipe multiple processes in Android:

object ProcessUtil {
    private fun check(process: Process, error: (() -> String)) {
        try {
            if (process.errorStream.available() > 0) {
                process.errorStream.bufferedReader().use {
                    it.readText().also { errorText ->
                        check(errorText.isBlank()) { "${error.invoke()} failed with error: $errorText" }
                    }
                }
            }
        } catch (_: IOException) {
            // ignore
        }
    }

    @Throws(IOException::class, IllegalStateException::class)
    fun pipe(vararg processes: ProcessBuilder): String {
        check(processes.size > 1) { "At least 2 processes are required" }

        var previous: Process? = null
        var result: String = ""

        processes.forEachIndexed { index, builder ->
            val cmdString = builder.command().joinToString(" ")
            println("Executing command $index -> $cmdString")

            if (index > 0) {
                check(builder.redirectInput() == Redirect.PIPE) { "Builder redirectInput must be PIPE except for the first builder" }
            } else if (index < processes.size - 1) {
                check(builder.redirectOutput() == Redirect.PIPE) { "Builder redirectOutput must be PIPE except for the last builder" }
            }

            val current: Process = builder.start()
            check(current) { cmdString }

            previous?.let { prevProcess ->
                prevProcess.inputStream.bufferedReader().use { reader ->
                    current.outputStream.bufferedWriter().use { writer ->
                        reader.readLines().forEach { line ->
                            println("writing --> $line")
                            writer.write(line)
                            writer.newLine()
                        }
                        check(current) { cmdString }

                    } // writer

                    if (index == processes.size - 1) {
                        current.inputStream.bufferedReader().use { reader2 ->
                            result = reader2.readText()
                        }
                    }

                } // reader
            }
            previous = current
        }
        return result
    }

    fun pipe(vararg commands: List<String>) = pipe(*commands.map { ProcessBuilder(it) }.toTypedArray())

    fun pipe(vararg commands: String) = pipe(*commands.map { ProcessBuilder(it) }.toTypedArray())
}

And it can be used like this:

    @Test
    fun testPipe() {
        val packageName = context.packageName
        val result = ProcessUtil.pipe(listOf("ps", "-A"), listOf("grep", packageName), listOf("awk", "{ print $9 }"))
        println("result = '$result'")
        Assert.assertTrue(result.contains(packageName))
    }

Photoshop Extension: Opacity Editor

Today I’d like to introduce an extension I’ve created for Adobe Photoshop: Opacity Editor.

I like to take pictures, ok, I’m definitely not a professional photographer, but I still like to try to be one 🙂

In particular I like to take pictures at night, I like to take pictures of moving clouds, stars.. etc. And then I like to stack all the photos into a single document. The problem is that most of the times this Photoshop document has hundreds, if not thousands, of layers (and we’re talking about 40MP images) and, despite the fact that photoshop can barely survive after I’ve added all those layers to the document, the real problem comes after that.. In order to create a nice stacking effect it’s not just a matter of changing the blending mode of the different layers, but also to change their opacity.

Unfortunately Photoshop doesn’t have an option to manage the opacity of multiple layers at the same time, like, for instance, from 0 to 100 of all the selected layers.. or even more advanced options.

That’s why I’ve decided to make an extension (available here) for this. This is it’s pretty easy interface:

So basically what it does is: you select a bunch of layers in your document, then you create the bezier curve. Once you click on “Apply Opacity” the opacity of every selected layer will follow the designed curve value. Let’s make it easier. If you design a curve like this:

the first selected layer will have an opacity of 0 (completely invisible), while the last layer will have 100 as opacity. (If you’ve selected 100 layers, then the second one will have an opacity value of 1, the third of 2 and so on..)

These 2 images, for instance, have been created using this extension:

 

1200~ pics

 

The second one is the result of 1200 layers stacked together and without that extension I would probably still working on it!

And here’s an example of the Extension in action:

 

For more information and download:
https://www.adobeexchange.com/creativecloud.details.20327.html

Why it’s hard to find Android developers

I’m an Android developer (at Aviary) and a New Yorker since late 2010. I’ve been also an actionscript and python developer for more than 10 years.

I hear this every day. Your New York based company is desperately looking for an Android developer, and it’s damn hard to find one.

And you know what? This is your fault. I mean you, tech startup. Yes, you.

Almost all the startups I know here in NYC have launched their core product for iOS first (most of the PMs I’ve spoken with have said that was because of lack of people or time). And then, maybe, if there’s time… they start to realize they need to have an Android version of their app. But by this point the job is not to “create” something, it’s just a “porting” job. Which, for obvious reasons, is a much less attractive job for developers, just as it would be for a PM or a designer. Continue reading →

Floating Action Menu for Android

I’ve just created this simple Android library, inspired from the recent changes to the Google+ app, which introduced a floating action item to the bottom. In G+ the action item appears and disappears based on the page scrolling.

In the same way I’ve created this library, with some more customization options, like the gravity and direction of the floating menu items.

Here’s a video of a sample implementation:

Source code and documentation can be find on the github project page:

https://github.com/sephiroth74/android-floating-action-menu

java.lang.UnsatisfiedLinkError workaround

If you ever worked with native shared libraries in Android you’ve probably already faced the “java.lang.UnsatisfiedLinkError” java exception which randomly seems to happen on certain devices ( actually it’s happening on xperia phones mostly, based on my reports ).
There are a bunch of bug reports in the android project like this: https://code.google.com/p/android/issues/detail?id=35962 or this https://code.google.com/p/android/issues/detail?id=64111.
The problem is that Google basically marked all of them as “resolved”, even if developers are still complaining about it.

Continue reading →

More Picasso changes

I recently added more changes to my forked version of the Picasso library.

Delay

Added withDelay(long ms) method in the RequestCreator class. Basically it will delay the load of the passed resource by n milliseconds.

[cce]
Picasso.with( context )
.load( url )
.withDelay( 100 )
.into( image );
[/cce]

Batch toggle on/off

In the original Picasso code all the “complete” events are dispatched using a batch operation, this means that you can have at the same time 3/4 “complete” events being triggered, which eventually can cause lags in the UI.
In this fork the batch is disabled by default and can be re-enabled by using:

[cce]
Picasso.with( context )
.setUseBatch( true );
[/cce]

Fork or download the source from GitHub:
https://github.com/sephiroth74/picasso

Link the library in your build.gradle:
compile “it.sephiroth.android.library.picasso:picasso:+”