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

Compile skia for android on Mac Lion

Some time ago I started to look at skia as possible solution for graphics 2D editing for a native android project I was developing.
Well, even if skia is part of the android system and it’s used everywhere by android itself, trying to include skia in my project was quite an hell..
Looking for resources I just found old examples and tips, and every try was just a failure.. but since android uses skia internally to do graphics operations I decided to look into the android project.

After downloading the skia module from the android git repository I just realized it couldn’t be compiled by itself because it has external dependencies. So next step was to download and compile the whole android source code. Easy task? not at all, at least if you’re on a mac running Lion!

At the end I managed to compile everything and build the skia module as static module, in this way now my project can link the skia library and include correctly the skia headers.

Ok, I don’t have the whole procedure step by step here, first of all because it depends on the android version you’re going to compiled, second and most important because I didn’t write down all the steps 🙂 So this is more a sort of list of notes about compiling android on Lion and a reminder for myself too. ( I was trying to compile android 2.2 using “generic-user” as lunch configuration )

Build Android: http://source.android.com/source/initializing.html

To get rid of clearsilver errors: http://code.google.com/p/android/issues/detail?id=993#c27

Java 1.5 version complaining: http://wiki.oneswarm.org/index.php/OS_X_10.6_Snow_Leopard

Well, the first time you’ll try to “make” everything probably you’ll get this error:

[cc]
./external/elfutils/config-compat-darwin.h:42: error: static declaration of ‘strnlen’ follows non-static declaration
[/cc]

modify ./external/elfutils/config-compat-darwin.h.
replace:
[cc lang=”c”]
static inline size_t strnlen (const char *__string, size_t __maxlen)
{
int len = 0;
while (__maxlen– && *__string++)
len++;
return len;
}
[/cc]

with:
[cc lang=”c”]
#if 0
static inline size_t strnlen (const char *__string, size_t __maxlen)
{
int len = 0;
while (__maxlen– && *__string++)
len++;
return len;
}
#endif
[/cc]

Well, at the end of the process I just edited the Android.mk makefile into external/skia adding a new entry for BUILD_STATIC_LIBRARY and the next command was simply:

[cc]mmm external/skia[/cc]

which produced the required libskia.a file to be linked in my project.

How-to debug native code with Android

This is a step by step guide for executing and debugging native code on Android with Eclipse.

1. Prerequisites

The SDK version used for this guide is Froyo with the NDK r4b ( crystax release ).
Also Eclipse CDT plugin it’s very useful for our purposes, so install it.
Last plugin to install it’s the Sequoyah plugin for Eclipse.

2. Project setup

At this point let’s create a new Android project, name it “Example” and use the “com.darkwavegames.com” package name, add also an Activity name of your choice.
Select Android 2.2 as base SDK version and complete the project wizard.

Now you need to add the native support for the newly created project. Just right click on the project root element in the package explorer and select “add native support”.

In the next dialog write the path of your NDK folder and give also name for your library.
After this operation a new folder “jni” will be created with a .cpp file, header filer and an Android makefile, Android.mk, which can be edited to modify all the includes, linker and compiler options. In the Android.mk file you also need to specify all the source file you want to use within the LOCAL_SRC_FILE directive.

3. Debug

In order to enable debug of native code in Android you have to face different problems, based also on the device and the firmware version, and if the native code is multi thread or single thread.
First of all you need to mofify the AndroidManifest.xml file adding the attribute “debuggable” to true ( remember also to enable the “Debug USB” option under the Application device menu ).
At this point you can debug all the java code within your eclipse debugger, for for the native C debug you need more steps.

Continue reading →

Android: Create your own sharing app

One of the first applications I did for Android was a very simple application which simply takes a picture using Camera and then send it to a remote server. Very simple, but it introduced to me into the android world and the notion of activities and communication between different applications.
A little addition to this application I wanted to make was the possibility to share any of the pictures already taken. To do that I wanted to add the application to the list of the choices which appear when an user click on the “share” button when view an image using the default Gallery application.

Continue reading →

Haxe got (awesome) macros!

Hi guys, It has been a while since I posted the last time.
However I felt like it was the right moment to go back on the blog, to talk you about the new awesome Haxe macro system.

As you can read on Nicolas’s blog, they’re only present in the latest SVN update, but they will be included in the next build as soon as they will be perfect.
I must say I’m extremely happy about that addition: one of the most important reasons I’m so happy is the fact that instead of implementing an old-and-bording Macro system ala C/C++, Nicolas decided to go for a much more powerful macro system, which allows typesafe macros and real runtime code generation.

You are not limited to simple stuff like replace a macro call with a block of source code, but you can generate any kind of valid AST expressions that will be evaluated by the macro preprocessor at compile time.
Moreover you have access to almost the whole neko library (io, net, etc) which means you can write extremely powerful code generators and DSL! That’s an awesome feature – really.

Give it a look!

Amethyst Actionscript editor

amethyst screenshotIt’s since the first time I used Eclipse that I’m waiting for an actionscript editor non eclipse based!

I never like eclipse and hated all its problems.
Unfortunately Adobe decided to implement its editor over eclipse and also other nice editors, like fdt, used the eclipse framework to build their own editors.
I used Visual Studio for a while but that was enough to make me fall in love with it.
Visual Studio is indeed the best editor at all, without doubts!
Finally I discovered an interesting project for editing actionscript projects integrated into Visual Studio.
Amethyst is still in beta and probably too premature to be used in large production environment, but it’s a great news for me.
I hope they’ll continue in the development and create a serious competitor for Flash Builder.
For more info visit their product page: http://www.sapphiresteel.com/Amethyst-Product-Page