- Published on
Self-Hosted File Sharing with NixOS Containers
I wanted a simple way to share files with friends. Not Google Drive, not Dropbox, not some sketchy "free" service that mines my data. Just a web interface where I can upload files and send people links.
The Obvious Issues:
- ISPs rotate consumer IPs every few days
- ISPs also block commonly used ports
- Running services directly on your main system feels dirty
- Traditional setups break when anything changes
Enter copyparty — a portable file server with a surprisingly good web UI, search, thumbnails, and chunked uploads. But getting it reliably accessible from the internet on a residential connection? That's where NixOS containers come in.
Architecture
Internet
↓
yourdomain.com (Cloudflare DDNS updates IP automatically)
↓
Router:443 (port forward)
↓
NixOS Host:443
↓
┌─────────────────────────────────────┐
│ Caddy Container (host networking) │
│ - TLS termination │
│ - Let's Encrypt via DNS challenge │
│ - Reverse proxy to copyparty │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Copyparty Container (10.10.10.x) │
│ - Private network namespace │
│ - Serves files on custom port │
│ - Bind-mounted storage from host │
└─────────────────────────────────────┘
Why this setup?
- DNS-01 challenge: No port 80 needed. Works even if your ISP blocks it.
- Automatic cert renewal: Caddy handles Let's Encrypt, you forget about it.
- Container isolation: Copyparty runs in its own network namespace.
- Survives IP rotation: OpenWRT DDNS updates Cloudflare when IP changes.
- Declarative everything: Blow it away, rebuild, same result.
Prerequisites
- A domain on Cloudflare (free tier works fine)
- OpenWRT router (or another DDNS solution)
- Port 443 forwardable from your router
- NixOS
Step 1: Static IP for Your Host
Your router's port forward needs a stable target. Don't trust DHCP.
# hosts/homeserver/configuration.nix
networking = {
hostName = "homeserver";
networkmanager.enable = true;
interfaces.eth0 = {
useDHCP = false;
ipv4.addresses = [{
address = "192.168.1.123";
prefixLength = 24;
}];
};
defaultGateway = "192.168.1.1";
nameservers = [ "192.168.1.1" "8.8.8.8" ];
};
WiFi still uses DHCP via NetworkManager. Ethernet gets the static IP.
Step 2: The Copyparty Container Module
Here's the flake that defines the copyparty container:
# modules/containers/copyparty/flake.nix
{
description = "Copyparty service in a NixOS container";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { nixpkgs, self }: {
nixosModules.copypartyContainer = { config, lib, ... }:
let
effectiveInterface =
if config.services.copypartyContainer.externalInterface != null then
config.services.copypartyContainer.externalInterface
else if config.networking.defaultGateway != null &&
config.networking.defaultGateway ? interface then
config.networking.defaultGateway.interface
else
null;
in {
options.services.copypartyContainer.externalInterface = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "External interface for NAT. Auto-detects if null.";
};
config = {
containers.copyparty = {
autoStart = true;
privateNetwork = true;
hostAddress = "10.10.10.1";
localAddress = "10.10.10.2";
bindMounts = {
"/srv/files" = {
hostPath = "/var/lib/copyparty/files";
isReadOnly = false;
};
"/etc/copyparty" = {
hostPath = "/var/lib/copyparty/config";
isReadOnly = true;
};
};
config = { pkgs, ... }: {
systemd.services.copyparty = {
description = "Copyparty file server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.copyparty}/bin/copyparty -c /etc/copyparty/copyparty.conf";
Restart = "on-failure";
DynamicUser = true;
ReadWritePaths = [ "/srv/files" ];
};
};
networking.firewall.allowedTCPPorts = [ 69420 ];
networking.useHostResolvConf = true;
system.stateVersion = "25.11";
};
};
# NAT for container's outbound traffic
networking.nat = {
enable = true;
internalInterfaces = [ "ve-copyparty" ];
} // lib.optionalAttrs (effectiveInterface != null) {
externalInterface = effectiveInterface;
};
systemd.tmpfiles.rules = [
"d /var/lib/copyparty/files 0755 root root -"
"d /var/lib/copyparty/config 0755 root root -"
];
};
};
};
}
Note: No port forwarding to the host. Caddy handles external access.
Step 3: The Caddy Container Module
This is where the magic happens. Caddy with the Cloudflare DNS plugin:
# modules/containers/caddy/flake.nix
{
description = "Caddy reverse proxy with Cloudflare DNS challenge";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { nixpkgs, self }: {
nixosModules.caddyContainer = { config, lib, pkgs, ... }: {
config = {
containers.caddy = {
autoStart = true;
privateNetwork = false; # Host networking - binds directly to 443
bindMounts = {
"/etc/caddy/secrets" = {
hostPath = "/var/lib/caddy/secrets";
isReadOnly = true;
};
};
config = { pkgs, ... }:
let
caddyWithCloudflare = pkgs.caddy.withPlugins {
plugins = [ "github.com/caddy-dns/cloudflare@v0.2.2" ];
hash = "sha256-ea8PC/+SlPRdEVVF/I3c1CBprlVp1nrumKM5cMwJJ3U=";
};
caddyfile = pkgs.writeText "Caddyfile" ''
{
email {env.ACME_EMAIL}
}
{env.CADDY_DOMAIN} {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
reverse_proxy {env.UPSTREAM_ADDRESS}
}
'';
in {
systemd.services.caddy = {
description = "Caddy reverse proxy";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = ''
${caddyWithCloudflare}/bin/caddy run \
--config ${caddyfile} \
--adapter caddyfile
'';
Restart = "on-failure";
DynamicUser = true;
StateDirectory = "caddy";
WorkingDirectory = "/var/lib/caddy";
EnvironmentFile = "/etc/caddy/secrets/cloudflare.env";
AmbientCapabilities = "CAP_NET_BIND_SERVICE";
CapabilityBoundingSet = "CAP_NET_BIND_SERVICE";
Environment = [
"HOME=/var/lib/caddy"
"XDG_DATA_HOME=/var/lib/caddy"
];
};
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
system.stateVersion = "25.11";
};
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
systemd.tmpfiles.rules = [
"d /var/lib/caddy 0755 root root -"
"d /var/lib/caddy/secrets 0700 root root -"
];
};
};
};
}
Getting the Plugin Hash
The first time you build, use a fake hash and Nix will tell you the real one:
hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
Build fails, gives you the correct hash. Update and rebuild.
Step 4: Wire It Into Your Flake
# flake.nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
copyparty.url = "path:./modules/containers/copyparty";
caddy.url = "path:./modules/containers/caddy";
# ... other inputs
};
outputs = { nixpkgs, copyparty, caddy, ... }: {
nixosConfigurations.homeserver = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./hosts/homeserver/configuration.nix
copyparty.nixosModules.copypartyContainer
{ services.copypartyContainer.externalInterface = "eth0"; }
caddy.nixosModules.caddyContainer
];
};
};
}
Step 5: Cloudflare Setup
Create API Token
- Cloudflare Dashboard → My Profile → API Tokens
- Create Token → Custom Token
- Permissions:
Zone:DNS:Edit - Zone Resources: Include your domain
- Save the token somewhere safe
Create DNS Record
In your domain's DNS settings, add an A record:
- Type:
A - Name:
@(or subdomain likefiles) - Content:
1.2.3.4(placeholder — DDNS will update this) - Proxy: DNS only (orange cloud OFF)
Important: Keep the proxy disabled. You want direct connections so Caddy can handle TLS.
Step 6: OpenWRT DDNS
If you're running OpenWRT:
opkg update
opkg install ddns-scripts ddns-scripts-cloudflare luci-app-ddns ca-certificates
In LuCi WebUI → Services → Dynamic DNS:
| Field | Value |
|---|---|
| Service | cloudflare.com-v4 |
| Lookup Hostname | yourdomain.com |
| Domain | yourdomain.com |
| Username | Bearer |
| Password | Your API token |
| Use HTTPS | Enabled |
| CA-Certificate | /etc/ssl/certs/ca-certificates.crt |
The DDNS script checks your IP every 10 minutes and updates Cloudflare if it changes. Your ISP can rotate your IP all they want — Cloudflare stays current.
Step 7: Secrets and Config
Caddy Secrets
After first rebuild, create the environment file:
sudo vi /var/lib/caddy/secrets/cloudflare.env
CLOUDFLARE_API_TOKEN=your_token_here
CADDY_DOMAIN=yourdomain.com
UPSTREAM_ADDRESS=10.10.10.2:69420
ACME_EMAIL=admin@yourdomain.com
Lock it down:
sudo chmod 600 /var/lib/caddy/secrets/cloudflare.env
Copyparty Config
sudo nano /var/lib/copyparty/config/copyparty.conf
p: 69420
e2dsa
e2ts
[accounts]
admin = $argon2id$YOUR_HASH_HERE
[/]
/srv/files
accs:
rw: admin
Generate a password hash:
nix-shell -p copyparty --run "copyparty --help-password"
Follow the prompts and paste the resulting hash into the config.
Step 8: Rebuild and Test
sudo nixos-rebuild switch --flake .#server
sudo nixos-container restart caddy
sudo nixos-container restart copyparty
Test it:
curl -I https://yourdomain.com
If everything's working:
HTTP/2 200
server: Caddy
Hit the URL in a browser and you should see the copyparty interface. Upload a file, share the link, feel the satisfaction of self-hosting.
Gotchas I Hit Along the Way
"Permission denied" on port 443
Copyparty with DynamicUser = true can't bind to privileged ports. Solution: Let Caddy handle 443, copyparty stays on 69420 (or whatever high port you want).
"An identical record already exists"
Stale _acme-challenge TXT record from a failed cert attempt. Delete it manually in Cloudflare DNS, then Caddy retries automatically on its next renewal cycle.
StateDirectory conflicts with bind mounts
DynamicUser + StateDirectory doesn't play nice with bind-mounting /var/lib/caddy. Solution: Only bind-mount the secrets to /etc/caddy/secrets, let systemd manage the state directory.
Caddy says "storage is probably misconfigured"
Missing HOME and XDG_DATA_HOME environment variables. Add them to the service config pointing to /var/lib/caddy. This one cost me a lot of head-scratching.
VPN Gotcha
If your router runs a VPN client for privacy, inbound connections break. The response packets go out the VPN tunnel instead of back to the original requester. Classic asymmetric routing problem.
Fix: Policy-based routing on OpenWRT.
opkg install pbr luci-app-pbr
Add a rule to bypass VPN for your server's IP:
- Source:
192.168.1.123(your server's static IP) - Interface:
wan
This ensures traffic to/from your server goes directly through your ISP connection instead of the VPN tunnel.
The Result
- HTTPS file server at
https://yourdomain.com - Auto-renewing Let's Encrypt certs (DNS challenge, no port 80 required)
- Survives ISP IP rotation automatically
- Containers isolate the services from your main system
- Declarative config, reproducible rebuilds
- Total monthly cost: $0 (assuming you already have a domain)
Total setup time after figuring out all the gotchas: about 30 minutes. Time spent figuring out the gotchas: several hours. You're welcome.
Links & Resources
- copyparty — The file server itself, surprisingly feature-rich
- Caddy — Reverse proxy with automatic HTTPS
- caddy-dns/cloudflare — DNS challenge plugin for Caddy
- NixOS Containers — systemd-nspawn with NixOS sugar
- Cloudflare API Tokens — Token creation docs
- OpenWRT DDNS — Dynamic DNS client docs