Building a Self-Hosted Family Digital Estate with Windows, Docker, Immich, Forgejo, Kopia and Homepage
I have been gradually turning a Windows machine at home into something more useful than a server running a collection of unrelated containers (See my previous blog post here: Building a Self-Hosted GitHub Mirror and Disaster-Recovery Backup with Forgejo, Kopia, Docker Desktop and Windows).
The goal is broader than a conventional homelab.
I want a family digital estate: a system that protects our photos, source code, documents and computers; provides simple applications that my family can actually use; monitors itself; and can eventually be understood and recovered by someone other than me.
The current platform now includes:
- Immich for our family photo and video archive
- Forgejo as a local mirror of my GitHub repositories
- Kopia for encrypted, versioned backups
- Veeam Agent for bare-metal Windows recovery
- Homepage as the central dashboard
- Uptime Kuma for availability monitoring
- Beszel for host resource monitoring
- Dozzle for Docker logs
- EasyTimesheets and DiaryBot as existing applications
- a nightly E: → F: storage mirror
- Docker Desktop and WSL2 as the application platform
There is still more to add — Paperless-ngx for family documents, BookStack for a family handbook, Tailscale for remote access and eventually an off-site backup — but the foundation is now functioning well enough that it is worth documenting.
The Design Principle
The most important architectural decision was that applications should not own my data.
Photos should remain photos.
Documents should remain ordinary files.
Git repositories should remain Git repositories.
Backups should be independently recoverable.
Applications such as Immich, Forgejo or Paperless are interfaces and indexes around the data. They are valuable, but they should be replaceable.
My preferred model is therefore:
Original data │ ├── normal filesystem │ ├── readable without the application │ └── independently backed up │ ▼ application/index layer
That is very different from putting everything inside opaque Docker volumes and hoping the application database never breaks.
Overall Architecture
At the moment, the system looks roughly like this:
Conceptually there are four layers:
Family data ↓ Applications ↓ Monitoring / operations ↓ Backup / disaster recovery
The Hardware and Storage Layout
The server runs Windows with Docker Desktop and WSL2.
I deliberately kept Windows as the host rather than rebuilding the machine around Linux because it also performs other Windows-specific duties and because I already have a substantial amount of locally attached storage.
The main storage currently looks approximately like this:
| Drive | Purpose |
|---|---|
C: | Windows, Docker Desktop and application infrastructure |
D: | Smaller backup/storage volume |
E: | Main large data volume |
F: | Mirror of E: |
G: | Additional storage |
H: | Additional storage |
The key relationship is:
E: = primary data F: = nightly exact mirror
For example:
E:\ ├── 01 - Photos ├── Immich ├── FamilyBackup ├── SystemBackups └── ...
That gives me one simple operational rule:
E: is the master. F: is a second local copy.
The mirror is not itself a versioned backup because accidental deletion on E: can also propagate to F:. Versioning is handled separately where appropriate by systems such as Kopia.
Networking
The server has a reserved LAN address:
192.168.1.239
That turned out to be more important than I initially expected.
Docker Desktop provides the special hostname:
host.docker.internal
which is useful for communication between containers and Windows.
However, I discovered that during some Docker Desktop restarts its internal DNS/networking layer can temporarily fail with errors such as:
getaddrinfo EAI_AGAIN host.docker.internal
For long-lived monitoring links, I therefore prefer the server's fixed LAN IP where practical:
http://192.168.1.239:2283 http://192.168.1.239:3000 http://192.168.1.239:3005
No general-purpose router port forwarding is used.
Remote access will eventually be provided through Tailscale rather than exposing these services directly to the Internet.
Immich: The Family Photo Archive
Immich is probably the application that makes the server most immediately useful to the rest of the family.
The historical photo archive already existed at:
E:\01 - Photos
I did not want Immich to reorganise or take ownership of that archive.
Instead, I configured it as a read-only external library.
The architecture is:
E:\01 - Photos │ │ read-only ▼ Immich
New uploads handled by Immich are stored separately:
E:\Immich\Managed
So Immich has two distinct storage roles:
Historical archive E:\01 - Photos READ ONLY │ ▼ Immich New managed uploads E:\Immich\Managed READ / WRITE
This separation is important.
If I ever decide to stop using Immich, the historical archive remains completely ordinary and untouched.
Docker Desktop and Large Windows Drives
This part became surprisingly interesting.
My first instinct was simply to bind-mount the Windows drive into Docker:
volumes: - E:/01 - Photos:/external/photos:ro
But Docker Desktop behaved incorrectly with this particular large drive.
Inside the container, Docker exposed something that looked like a tiny filesystem instead of the real multi-terabyte Windows volume.
Marker files created on the real E: drive were invisible inside Docker.
A write test also failed after only a small amount of data.
Rather than fighting Docker Desktop's Windows-drive translation layer, I bypassed it completely.
SMB as a Windows-to-Docker Storage Bridge
I created dedicated Windows SMB shares and mounted them into Docker as CIFS volumes.
For Immich:
Windows E:\01 - Photos ↓ SMB share ImmichExternalPhotos ↓ Docker CIFS volume ↓ /external/photos
And:
Windows E:\Immich\Managed ↓ SMB share ImmichManaged ↓ Docker CIFS volume ↓ Immich
A dedicated Windows account is used for the share permissions rather than granting broad access.
The external photo archive has read-only permissions.
The managed Immich area has write permissions.
This turned out to be substantially more reliable than direct Docker Desktop bind mounts for these drives.
Immich Database Placement
I did not put PostgreSQL on NTFS or SMB.
The Immich PostgreSQL database remains in a Docker-managed Linux volume.
That produces an important split:
Large media → Windows NTFS / SMB Database → Docker Linux volume
This gives PostgreSQL the filesystem semantics it expects while allowing terabytes of media to remain on normal Windows storage.
Initial Immich Indexing
Once the external library was added, Immich began scanning years of photos and videos.
During the initial import it was quite resource hungry.
That is expected because Immich may simultaneously be doing:
metadata extraction thumbnail generation video processing face processing machine-learning jobs database indexing
After the initial processing completes, the system should settle considerably.
Because the external library is a network-mounted SMB filesystem from the container's perspective, I do not rely solely on filesystem watchers.
Instead I can run periodic library scans.
That means I can continue manually adding files to:
E:\01 - Photos
and Immich discovers them during its next scan.
Forgejo: A Local GitHub Mirror
The next major part of the digital estate is source code.
GitHub remains the primary development platform.
I did not want to change my normal workflow.
The topology is:
Development machines │ ▼ GitHub │ ▼ Forgejo pull mirrors │ ▼ Kopia
Forgejo therefore acts as a local read-only replica, not as the canonical Git server.
Automated Repository Mirroring
I built a small synchronisation service that queries GitHub and compares the result with Forgejo.
It:
- includes repositories owned by my GitHub account
- includes private repositories
- includes archived repositories
- includes forks I own
- ignores employer or organisation repositories that I do not own
- creates missing Forgejo mirrors
- does not automatically delete existing mirrors
The first run discovered:
358 GitHub repositories
and successfully created local mirrors.
The entire repository set consumed only around:
6 GB
which is trivial relative to the available storage.
What Git Mirroring Does Not Back Up
A Git mirror is extremely useful, but it is not a complete GitHub backup.
It preserves Git data:
commits branches tags history
It does not automatically preserve everything associated with GitHub itself, including things such as:
Issues Pull Requests Discussions Actions artefacts some release assets repository settings secrets packages
That distinction matters.
Forgejo provides excellent source-code resilience, but GitHub metadata would require additional export mechanisms if I wanted a truly complete archive.
Kopia: Versioned Backup for Forgejo
Forgejo itself then needs backup protection.
I chose Kopia because it provides:
encryption deduplication versioning retention policies repository validation snapshot verification
The repository currently lives under:
E:\FamilyBackup\Kopia\HomeServer
Once again I encountered the Docker Desktop large-drive issue, so the repository is exposed through SMB/CIFS rather than a direct E: bind mount.
Creating a Consistent Forgejo Backup
Forgejo uses SQLite in my current deployment.
Simply backing up the live Docker volume while the database is being written to is not ideal.
Instead the process is:
Stop Forgejo briefly ↓ TAR the complete Docker volume ↓ Restart Forgejo immediately ↓ Kopia snapshots the TAR ↓ Kopia snapshots configuration ↓ Delete temporary TAR
The resulting downtime is short, while the snapshot represents a consistent application state.
Backup Retention
Kopia is configured with a fairly generous retention policy:
Latest: 10 Hourly: 24 Daily: 30 Weekly: 12 Monthly: 24 Annual: 10
Storage is inexpensive compared with discovering several months later that the only usable backup was automatically deleted.
Actually Testing the Backup
A backup that has never been restored is only a theory.
I therefore performed a full recovery test.
The process was:
Kopia snapshot verification ↓ 100% file-data verification ↓ Restore Forgejo TAR ↓ Create temporary Docker volume ↓ Extract restored TAR ↓ Start temporary Forgejo instance ↓ Open repository in browser
The restored instance contained all:
358 repositories
and successfully booted from the restored SQLite database.
I could browse actual source files and commit history.
That test is far more valuable than seeing:
Backup completed successfully
in a log.
Veeam: Bare-Metal Recovery
Application backups do not solve the problem of the Windows system SSD failing.
For that I use Veeam Agent for Windows.
The objective is simple:
SSD fails ↓ replace SSD ↓ boot Veeam Recovery Media ↓ select latest system image ↓ Bare Metal Recovery ↓ Windows + Docker + applications restored
The important distinction is that Veeam Recovery Media is only the boot environment.
The actual machine state lives in the Entire Computer backup.
The home-server Veeam job therefore protects the system/EFI/recovery partitions and Windows SSD.
Large external data disks are not part of the machine image because they have their own storage and backup strategy.
E: to F: Nightly Mirror
For many years I maintained secondary disk copies manually.
I decided to formalise that process using Robocopy.
The relationship is intentionally:
E: MASTER ↓ F: MIRROR
The job uses:
/MIR
because I specifically want F: to be an exact mirror of E:.
A simplified version looks like:
robocopy E:\ F:\ ` /MIR ` /COPY:DATS ` /DCOPY:DAT ` /ZB ` /XJ ` /R:3 ` /W:5 ` /MT:16
System directories such as these are excluded:
System Volume Information $RECYCLE.BIN
Robocopy return values also need to be handled carefully.
Values from:
0–7
can indicate successful operations or acceptable differences.
Values:
8+
represent failures.
That matters when integrating Robocopy with Task Scheduler and monitoring.
Homepage: Turning Everything into One System
The biggest usability improvement came from installing Homepage.
Before Homepage, the environment consisted of a growing collection of ports:
:2283 :3000 :3005 :3006 :8080 :8088 :8090 ...
That is manageable for the person who built it.
It is not a pleasant user interface.
Homepage changes that.
The main portal is:
http://192.168.1.239:3005
and becomes the front door to the environment.
Current Homepage Structure
The dashboard is organised conceptually into:
Family Development Applications Infrastructure
For example:
FAMILY ┌────────────────────┐ │ Immich │ │ Photos and videos │ └────────────────────┘ DEVELOPMENT ┌────────────────────┐ │ Forgejo │ │ GitHub mirrors │ └────────────────────┘ APPLICATIONS ┌────────────────────┐ │ EasyTimesheets │ └────────────────────┘ ┌────────────────────┐ │ DiaryBot │ └────────────────────┘ INFRASTRUCTURE ┌────────────┐ ┌────────────┐ ┌────────────┐ │ Kuma │ │ Beszel │ │ Dozzle │ └────────────┘ └────────────┘ └────────────┘
The difference is surprisingly significant.
Instead of asking:
What port was Beszel on?
I simply open the home portal.
Secure Docker Integration
Homepage can query Docker to display container status and resource information.
I did not want to give the Homepage container unrestricted access to:
/var/run/docker.sock
because Docker socket access is effectively administrative access to the host's container environment.
Instead I introduced:
docker-socket-proxy
Homepage communicates with that proxy.
The proxy permits only the API operations Homepage needs.
Conceptually:
Homepage │ ▼ Docker Socket Proxy │ ▼ Docker Engine
rather than:
Homepage │ ▼ FULL Docker socket access
It is a small improvement, but a worthwhile one.
Uptime Kuma: Is It Actually Working?
Docker reporting:
container = running
does not mean:
application = working
A web application can be running while its database is unavailable.
A process can exist while its HTTP endpoint returns errors.
That is where Uptime Kuma fits.
I currently monitor or plan to monitor:
| Service | Check |
|---|---|
| Internet | Ping |
| Router | Ping |
| Homepage | HTTP |
| Immich | HTTP |
| Forgejo | HTTP |
| Beszel | HTTP |
| Dozzle | HTTP |
| DiaryBot | TCP |
| Windows SMB | TCP |
| EasyTimesheets | external HTTP |
A useful example is DiaryBot.
Its root HTTP route may not represent application health correctly, so instead of forcing an inappropriate HTTP test, Kuma can simply verify that its TCP port is listening.
Monitoring the Whole Path
For EasyTimesheets, I deliberately do not monitor only the local container.
The application is externally exposed through Cloudflare Tunnel.
The most useful monitor is therefore the actual external application URL.
That checks:
Kuma ↓ Internet ↓ Cloudflare ↓ Tunnel ↓ Application
rather than merely:
Kuma ↓ container
The former tests what the user actually depends upon.
Beszel: Monitoring Windows, Not Just Docker
Docker resource graphs only tell part of the story.
My storage is primarily attached to Windows.
The information I actually care about includes:
Windows CPU Windows RAM network utilisation C: usage D: usage E: usage F: usage G: usage H: usage disk I/O
That is why Beszel runs with a native Windows agent.
The Beszel server itself runs in Docker, but the monitoring agent runs directly on Windows.
That produces this topology:
Windows host │ Beszel Agent │ ▼ Beszel Server │ ▼ Homepage
The additional drives can be exposed to the agent using:
EXTRA_FILESYSTEMS
so Beszel becomes the authoritative view of host storage and resource usage.
Dozzle: The Fastest Way to Debug Containers
Dozzle has one job:
show Docker logs
It does that extremely well.
Instead of repeatedly running commands such as:
docker logs immich_server --tail 100
I can open Dozzle and immediately switch between:
Immich PostgreSQL Redis Forgejo Homepage Kuma Beszel DiaryBot EasyTimesheets Cloudflared
This creates a very useful operational chain:
Homepage ↓ something looks wrong Uptime Kuma ↓ confirm whether the service is actually down Beszel ↓ check CPU / RAM / disk / host health Dozzle ↓ read application logs
Each tool answers a different question.
Existing Applications
The server is not only an infrastructure platform.
It also hosts applications I already use.
Two examples are:
EasyTimesheets
My timesheet application runs in Docker and is currently exposed externally through Cloudflare Tunnel.
The local application port is deliberately bound only to:
127.0.0.1
rather than exposing it to the whole LAN unnecessarily.
Cloudflared handles the external path.
DiaryBot
DiaryBot is another local application running in Docker.
It is accessible on the LAN and appears directly in Homepage alongside the infrastructure services.
As additional applications are created, I plan to make Homepage registration part of their deployment convention.
Eventually a new application should effectively mean:
docker compose up -d + Homepage metadata + Kuma monitor + backup policy
rather than manually rediscovering the service months later.
Docker Desktop: Lessons Learned
This build has also exposed several Docker Desktop quirks.
The most significant were not application problems at all.
1. Large Windows Drive Bind Mounts
Directly mounting one of my large Windows volumes into Docker produced incorrect behaviour.
The eventual solution was:
Windows NTFS ↓ SMB ↓ Docker CIFS volume
This has been substantially more reliable.
2. Startup Networking Can Lag Behind Containers
After a Windows reboot, Docker Desktop sometimes reached a state where containers reported:
Up
but networking was not completely healthy.
Homepage logged errors such as:
getaddrinfo EAI_AGAIN
and the Docker API itself briefly returned:
500 Internal Server Error
The lesson was not to create aggressive self-healing scripts that immediately recreate containers.
I briefly experimented with such a script and discovered that automatic recovery can make an unstable Docker engine worse.
The safer strategy is:
Docker restart policies + monitoring + manual intervention when Docker itself is unhealthy
rather than continuously forcing container recreation.
3. Test the Application from Both Sides
At one point Uptime Kuma, Beszel and Dozzle were all inaccessible from Windows.
Inside Docker, however:
KUMA OK DOZZLE OK BESZEL OK
The applications were healthy.
The broken component was Docker Desktop's published-port forwarding.
This test was invaluable:
docker run --rm --network monitoring alpine sh -c ` "wget -qO- http://uptime-kuma:3001 >/dev/null && echo 'KUMA OK'"
It allowed me to isolate:
application ✓ Docker internal network ✓ Windows port forwarding ✗
rather than blindly reinstalling applications.
Restart Policies
All long-running infrastructure containers use:
restart: unless-stopped
For example:
services: homepage: restart: unless-stopped uptime-kuma: restart: unless-stopped dozzle: restart: unless-stopped beszel: restart: unless-stopped
This means Docker can restore them after its engine starts.
I prefer this simple Docker-native behaviour over an overly aggressive Windows scheduled script that continually manipulates containers.
The Monitoring Model
The combination of Homepage, Kuma, Beszel and Dozzle now gives me four distinct layers.
Or in plain English:
Homepage → Where is everything? Kuma → Is it up? Beszel → Is the server healthy? Dozzle → Why did it fail?
That is a much better operational model than checking docker ps and assuming everything is fine.
Backup Monitoring Is the Next Step
The next improvement is to make backups first-class citizens on Homepage.
I want a section that eventually looks something like:
BACKUPS ────────────────────────────────────────────────────── Veeam Home Server ✓ Last successful backup: 01:14 Forgejo / Kopia ✓ Last successful snapshot: 03:02 E → F Mirror ✓ Last successful mirror: 04:37 Immich Database ✓ Last database backup: 02:00
The underlying information already exists.
It can be collected from:
Windows Task Scheduler Kopia snapshot metadata Robocopy logs Veeam backup files Immich database dumps
A small local status API can expose that as JSON.
Homepage supports custom API widgets, so this should integrate cleanly.
Kuma Push Monitors for Backups
There is another useful approach.
Scheduled backups can be represented by push monitors in Uptime Kuma.
Instead of Kuma asking:
Are you alive?
the backup job tells Kuma:
I completed successfully.
The flow becomes:
Backup starts ↓ backup completes ↓ success URL called ↓ Kuma updates monitor
If Kuma stops receiving successful updates within the expected period, the backup monitor goes red.
That will be particularly useful for:
Veeam Kopia E → F mirror Immich database backup
A backup job silently failing for three months is much more dangerous than a web application being unavailable for ten minutes.
Family Documents: Paperless-ngx Is Next
Photos are now handled well.
The next major data category is family documentation:
passports birth certificates insurance policies house documents bills medical correspondence school documents receipts contracts warranties
The planned interface for this is Paperless-ngx.
The existing documents will remain preserved initially.
Paperless will ingest copies and provide:
OCR full-text search tags document types correspondents user accounts permissions
Instead of knowing the exact folder hierarchy, a family member should eventually be able to search:
Martina passport
or:
home insurance 2026
and immediately retrieve the document.
Planned Family Section
Eventually I want Homepage to look something like:
FAMILY ──────────────────────────────────────────── Immich Photos and videos Family Documents Paperless-ngx Family Handbook BookStack
These three applications serve very different purposes.
Immich answers:
Where are our photos?
Paperless answers:
Where is the document?
BookStack answers:
How does something work?
BookStack: Documentation for Humans
This entire system should not depend on me remembering how it works.
Eventually BookStack will contain a family-oriented operational handbook.
For example:
Family Handbook Home ├── Emergency contacts ├── House information ├── Insurance └── Utilities Technology ├── Home server overview ├── How to recover the server ├── How to restore photos ├── How to restore Forgejo ├── Where backups are stored └── What to do if I am unavailable
Passwords will not be stored there.
They belong in a password manager.
BookStack should explain the system, not become another secrets database.
Tailscale: Remote Access Without Opening the Router
The next networking step will be Tailscale.
The target topology is:
Home Server │ Tailscale │ ├── my phone ├── my laptop └── my wife's phone
That will allow secure remote access to services such as:
Homepage Immich Paperless
without exposing application ports directly to the public Internet.
I want the router to remain deliberately boring:
no random forwarded ports
The Remaining Weakness: Off-Site Backup
At the moment the local resilience is becoming quite strong.
I have:
primary data + local mirror + versioned backups + bare-metal Windows recovery
But multiple disks inside the same home are still ultimately one location.
Fire, theft, electrical damage or another catastrophic event could affect several copies at once.
The final major piece therefore needs to be:
encrypted off-site copy
of the genuinely irreplaceable data.
Primarily:
family photos important documents critical configuration source-code backups
That could eventually be another physical location, cloud object storage, or a combination of both.
What the Finished System Should Become
The end state I am working toward looks approximately like this:
CORBILLA HOME │ ┌──────────────────────┼───────────────────────┐ │ │ │ ▼ ▼ ▼ FAMILY APPLICATIONS DEVELOPMENT │ │ │ Immich EasyTimesheets Forgejo Paperless DiaryBot BookStack │ │ ▼ DATA │ ├────────── E: Primary │ └────────── F: Mirror INFRASTRUCTURE │ ┌───────────┼───────────┐ ▼ ▼ ▼ Kuma Beszel Dozzle availability host logs health BACKUPS │ ┌────────────────┼────────────────┐ ▼ ▼ ▼ Kopia Veeam E → F Mirror │ └──────── encrypted/versioned │ ▼ future off-site copy
What I Like About This Architecture
The biggest success so far is that no individual component has become indispensable.
If Homepage disappears, the applications still work.
If Immich disappears, the original photos remain.
If Forgejo disappears, GitHub remains the primary source.
If GitHub disappeared, Forgejo has the Git repositories.
If the Windows SSD fails, Veeam can rebuild the machine.
If Forgejo becomes corrupted, Kopia can restore it.
If E: fails, F: contains a second local copy.
That is exactly how I want a home infrastructure system to behave.
There should not be a single magical database somewhere that suddenly contains my family's entire digital life.
Some Lessons So Far
The project has already reinforced several principles.
Keep original data boring
Ordinary directories and files are excellent long-term storage formats.
Do not make an application database the only place an important file exists.
Backup and replication are different things
Forgejo is a replica of GitHub.
F: is a replica of E:.
Neither automatically provides meaningful historical versioning.
Kopia does.
Test restores
A successful backup log is not equivalent to a successful recovery.
Boot the restored application.
Open the restored repository.
Read the restored file.
Monitor outcomes, not processes
A running container does not prove that its service works.
A scheduled task completing does not necessarily prove that the backup is recoverable.
Monitoring should get as close as practical to the actual result that matters.
Avoid exposing unnecessary services
The LAN does not need every database port.
The Internet definitely does not.
Use internal Docker networks, localhost bindings, Cloudflare Tunnel where appropriate, and eventually Tailscale.
Automation should fail safely
An automation that detects a problem is useful.
An automation that automatically rebuilds infrastructure while the underlying Docker engine is malfunctioning can make matters worse.
Recovery automation should be conservative.
Current Status
At this point the core environment is working:
✓ Immich photo archive ✓ historical photos mounted read-only ✓ managed Immich storage ✓ Forgejo GitHub mirrors ✓ automated repository discovery ✓ Kopia encrypted backup ✓ tested Forgejo disaster recovery ✓ Veeam bare-metal backup configuration ✓ E → F local mirror ✓ Homepage dashboard ✓ Uptime Kuma ✓ Beszel ✓ Dozzle ✓ EasyTimesheets ✓ DiaryBot
The next priorities are:
1. Finish Kuma monitor configuration 2. Finish Beszel Windows-host monitoring 3. Add backup-health reporting 4. Verify recurring Veeam backups 5. Configure laptop bare-metal backup 6. Deploy Paperless-ngx 7. Add Tailscale 8. Add BookStack 9. Add encrypted off-site backup
Conclusion
What started as a few Docker containers is gradually becoming a proper family infrastructure platform.
The interesting part is not any individual application.
Immich is excellent.
Forgejo is useful.
Kopia is powerful.
Homepage looks great.
But the real value comes from connecting them into a system where each component has a clear responsibility:
Immich → preserve and browse memories Paperless → find documents Forgejo → replicate source code Kopia → preserve historical versions Veeam → recover computers Homepage → navigate everything Kuma → tell me whether it works Beszel → tell me whether the server is healthy Dozzle → tell me what went wrong
That is the direction I want the project to continue taking.
Not simply a homelab.
A self-hosted family digital estate that is understandable, recoverable and designed to survive the failure of any single application or machine.

Comments
Post a Comment