Published on

Self-Hosted File Sharing with NixOS Containers

Authors
  • avatar
    Name
    Johanness Nilsson
    Mastodon

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

  1. Cloudflare Dashboard → My Profile → API Tokens
  2. Create Token → Custom Token
  3. Permissions: Zone:DNS:Edit
  4. Zone Resources: Include your domain
  5. Save the token somewhere safe

Create DNS Record

In your domain's DNS settings, add an A record:

  • Type: A
  • Name: @ (or subdomain like files)
  • 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:

FieldValue
Servicecloudflare.com-v4
Lookup Hostnameyourdomain.com
Domainyourdomain.com
UsernameBearer
PasswordYour API token
Use HTTPSEnabled
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.