The Black Cat
How I Turned copyparty into a Tilde Without Shell Access
The idea, the architecture, and the building of a personal-web community on a server that was already alive
First Things First: Asking the Right Question
The idea began with a small, almost casual question: if copyparty can serve files, deliver an index.html, and apply different permissions per user and per directory, why couldn't I use it as the foundation for a tilde?
At first, the connection seemed slightly crooked. A traditional tilde is a shared Unix machine. A user receives an account, logs in over SSH, gets a home directory, works inside public_html, learns commands, runs programs, talks to other people, and gradually starts inhabiting the server rather than merely publishing through it. Tilde.club describes that tradition as access to a shared Unix computer where people create web pages, learn, and share knowledge.
copyparty, by contrast, presents itself as a portable file server: a browser interface, resumable uploads, WebDAV, SFTP, indexing, accounts, volumes, and directory-level permissions. It would have been easy to look at it and see nothing more than a strange, unusually capable Dropbox.
I saw something else: a publishing panel for the personal web.
The trick was not pretending that copyparty was a multi-user Unix system. It is not. The trick was separating tilde culture from one specific implementation. I wanted to preserve the parts that mattered to me: personal pages, /~username/ URLs, a small community, quotas, rules, a member directory, the freedom to edit HTML, and that old feeling that the web can still be made by hand.
I was willing to give up shell access in exchange for a much friendlier front door.
The Project's Thesis
A tilde does not have to lose its soul merely because access to
public_htmlhappens through a browser. I was not building a complete pubnix. I was building a deliberately limited webtilde: safer, more approachable, and centered on publishing.
What I Wanted to Preserve โ and What I Chose Not to Imitate
Before installing anything, I had to be honest about the idea. If I called any public folder containing HTML a tilde, the word would become decoration. So I defined a minimum cultural core.
| I wanted to preserve | I would not try to reproduce |
|---|---|
Personal pages at /~username/ |
Unix shell access for members |
| A small, recognizable community | Persistent user processes |
| HTML, CSS, images, text, and small experiments | Arbitrary compilers and runtimes |
| Quotas and human approval | Open, automatic registration |
| A member directory and public rules | Personal services and custom ports |
| Real files outside a CMS | PHP, databases, or WordPress per member |
That limitation was not a hidden defect. It was the product.
A member would receive static space, an editor account, a quota, and a URL. They would not receive access to Debian, Docker, Pangolin, Traefik, or any other part of the infrastructure.
That drastically reduced the attack surface and removed much of the thankless work involved in operating an open pubnix: fork bombs, cryptomining, abandoned processes, arbitrary listening ports, compilers, Unix permission puzzles, quota evasion, and CPU abuse.
Why copyparty Fit Better Than It First Appeared
copyparty had three characteristics that changed the entire discussion.
The first was its volume model: a URL can be mapped to a real directory, and each volume can receive permissions per user.
The second was the web interface. It could upload, rename, move, delete, and edit files without requiring members to understand SSH, SCP, Unix ownership, or command-line editors.
The third was the ability to treat a directory as a traditional website, serving index.html instead of exposing a file listing.
On top of that, copyparty already provided the unglamorous pieces I did not want to rebuild:
- authentication;
- password hashing;
- resumable uploads;
- per-volume storage limits;
- maximum file counts;
- maximum individual file sizes;
- minimum free-disk reserves;
- indexing;
- account and volume reloads without bringing down the entire process.
The detail that made the project genuinely possible was realizing that the same files could be presented by two separate copyparty instances.
One instance would be an authenticated editor with write access.
The other would be an anonymous, read-only public server.
That split looks obvious after the fact. Before it, the project was merely a charming idea. After it, it became architecture.
The Server Was Not a Blank Slate
I did not begin with an empty VPS. The machine already mattered.
It was a Debian 13 virtual machine running under QEMU and serving as one of the central points in my infrastructure. Before the project, it had roughly 8 GiB of RAM and 150 GB of storage. I upgraded it to around 17 GiB of RAM, 1 TB of disk, and added 4 GiB of swap.
Storage stopped being the problem. The real risk became breaking what was already working.
The server already hosted several components:
- Pangolin, Gerbil, Traefik, and CrowdSec under Docker Compose;
- a Keila installation with PostgreSQL;
- an FRP server running directly on the host;
- Tailscale, SSH, SNMP, Exim, cron, and the usual Debian services;
- ports 80 and 443 already owned by the Gerbil/Traefik path;
- an external Docker network named
pangolin, used by local services that needed to reach the proxy.
That imposed a rule that guided the entire build:
copyparty would not receive its own public host port.
There would be no 3923:3923 mapping followed by crossed fingers. The containers would remain invisible on the host and would be reachable only through the existing proxy on the existing Docker network.
The First Real Step Was Installing Nothing
Before creating the project, I audited the server.
I inventoried:
- CPU;
- memory and swap;
- disks and filesystems;
- mounts;
- listening TCP and UDP ports;
- systemd services and timers;
- local users;
- cron jobs;
- firewall state;
- Docker containers;
- Docker networks;
- volumes and bind mounts;
- Compose projects;
- logs;
- the directories consuming the most storage.
That may sound excessively cautious, but it was the opposite. It was the fastest way to stop guessing.
The audit exposed an important topology. Traefik did not publish ports directly; it shared Gerbil's network namespace. Gerbil was the component actually holding ports 80 and 443.
It also showed that the existing projects relied on bind mounts rather than named Docker volumes, and that /srv was almost empty. That was where I decided to create /srv/theblack-cat as the isolated root of the experiment.
A Rule I Kept Afterwards
On a server with history, installing first and understanding later is the wrong order. An inventory is not bureaucracy. It tells you where a new service can exist without competing for ports, networks, storage, or authority with production systems.
The Architecture That Solved the Problem
The final design used two persistent copyparty instances, two web origins, and one content tree.
Internet
|
v
Traefik / HTTPS
|
+--> theblack.cat
| |
| +--> copyparty-public
| /w/sites (read-only)
| /w/community (read-only)
|
+--> edit.theblack.cat
|
+--> copyparty-editor
/w/sites (read-write)
/w/community (read-write)
The main domain became the display window. The edit subdomain became the file-management panel.
When I changed /community/index.html through the editor, that same file was already being read by the public instance. There was no deployment step, no publish button, no build pipeline, and no intermediate copy.
Saving was publishing.
At the same time, the public container mounted the shared directories with :ro. Even if an application flaw attempted a write, Docker's mount policy would block it.
That redundancy was intentional:
- copyparty permissions at the application layer;
- read-only mounts at the container/filesystem layer.
Foundation: A Service Identity and a Predictable Tree
I created a system user named theblackcat, with no password and /usr/sbin/nologin as its shell. It did not join the docker group and received no SSH access.
The pablo account would exist inside copyparty. It did not need to become another human Linux account.
/srv/theblack-cat/
โโโ config/
โ โโโ editor/
โ โโโ public/
โโโ sites/
โโโ community/
โโโ members/
โโโ secrets/
โโโ state/
โโโ hists/
โโโ administration/
โโโ policies/
โโโ audit/
โโโ backup/
โโโ scripts/
โโโ release/
From the start, I treated configuration, content, runtime state, and secrets as different categories.
- Configuration could be read by containers.
- Session state needed to be writable, but isolated per instance.
- Member sites were writable only in the editor.
- Secrets used mode
0600. - The public service never received the authentication file.
That separation made later decisions much easier because each path already had a clear purpose and trust level.
Governance Before Registration
The system began as a closed alpha.
I deliberately did not start with a signup form, because receiving applications was not the difficult problem. The difficult problem was deciding what happened after someone applied.
So governance came before automation.
I established that every account and every quota required my explicit approval. The administrative account received 10 GiB because it would also be used for testing and for the community website.
Ordinary members could receive one of four approved allocations:
- 100 MiB;
- 250 MiB;
- 500 MiB;
- 1024 MiB.
The requested value would never become the approved value automatically.
I also set limits that made sense for personal websites:
- 5,000 files;
- 50 MiB per individual file;
- a global reserve of 100 GiB free on the server.
The goal was never to host video libraries. The goal was to prevent a webtilde from accidentally becoming a file locker.
The Editor Configuration
Inside the editor instance, each member receives a dedicated volume.
In my case, /~pablo pointed to /w/sites/pablo. The administrative A permission remained exclusive to me. Ordinary members would receive rwmd, enough to read, upload, move, and delete their own files without receiving application-level administrative power.
[/~example]
/w/sites/example
accs:
rwmd: example
flags:
e2ds
nohtml
xvol
vmaxb: 250m
vmaxn: 5k
sz: 1-50m
df: 100g
The nohtml flag was crucial.
The editor needed to manipulate HTML without executing a member's HTML inside the authenticated origin. With nohtml, HTML and SVG are returned as text, and related rendering behavior is restricted.
That distinction is subtle but important:
The place where I log in should not behave like the website a member is building.
The quotas use vmaxb and vmaxn. Because those controls depend on volume indexing, each volume also enables e2ds.
copyparty performs the accounting. I did not need to build another layer just to count files and bytes.
The Public Configuration
The public instance is almost the inverse.
It knows nothing about accounts and never receives the authentication secret. Each personal site receives only the h permission, which serves the directory as a traditional website and delivers index.html without turning the domain into a file browser.
[/~example]
/w/sites/example
accs:
h: *
flags:
noscript
xvol
norobots
cachectl: no-cache
During the alpha, I kept noscript enabled. That was not a religious position against JavaScript. It was a way to reduce variables while I validated the model. HTML and CSS were more than enough to prove the concept.
Later, the institutional community website received the root volume /, pointing to /w/community. The /~username volumes remained as child volumes and shadowed the root where appropriate.
The result was simple:
- the community homepage lived at
theblack.cat/; - personal pages remained at
theblack.cat/~username/.
Containers: What I Explicitly Refused to Give Them
The image was pinned by digest rather than latest.
Both containers run as the UID and GID of the technical service account, with:
- a read-only root filesystem;
- all Linux capabilities dropped;
no-new-privilegesenabled;- memory limits;
- PID limits;
- log rotation;
- health checks;
- no Docker socket;
- no privileged mode;
- no host networking;
- no published host ports.
An abbreviated Compose example looks like this:
services:
editor:
image: copyparty/ac@sha256:EXAMPLE_DIGEST
user: "999:986"
read_only: true
restart: unless-stopped
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 256
mem_limit: 1g
volumes:
- ./sites:/w/sites:rw
- ./community:/w/community:rw
public:
image: copyparty/ac@sha256:EXAMPLE_DIGEST
user: "999:986"
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
- ./sites:/w/sites:ro
- ./community:/w/community:ro
networks:
pangolin:
external: true
The external network became the bridge between separate Compose projects.
Services could be resolved by name inside the pangolin network while the host continued to expose no listener on port 3923. That avoided creating a second public entry point or duplicating the proxy stack.
The Session-State Problem That Appeared Only Because I Did Things Properly
During isolated tests, copyparty warned that it had no safe writable location for session state.
/cfg was mounted read-only, and the service account did not have a useful writable home directory. The easy answer would have been enabling an unsafe option.
I chose to fix the cause instead.
I created separate state directories for the editor and public instances, mounted each one at /state, and set:
HOME=/state
XDG_CONFIG_HOME=/state/xdg
After that, the editor created a real session database. The warning disappeared without weakening the container.
This was a useful reminder that hardening is not simply a list of restrictions. A service still needs explicit writable locations for the state it legitimately owns.
Putting the Project Behind the Infrastructure That Already Existed
The first plan was to register both resources directly in Pangolin.
Discovery revealed an important detail: the available Pangolin sites represented remote tunnels. There was no local site capable of resolving containers on that same VPS.
Forcing the model would have produced a resource that looked elegant in the dashboard and failed in practice.
The solution was to use Traefik's file provider, already the established pattern for other local services on that machine.
I added two routers and two services while preserving:
- HTTP-to-HTTPS redirection;
- security headers;
- automatic certificate issuance.
A simplified example:
http:
routers:
blackcat-public:
rule: Host(`theblack.cat`)
entryPoints:
- websecure
service: blackcat-public
tls:
certResolver: letsencrypt
blackcat-editor:
rule: Host(`edit.theblack.cat`)
entryPoints:
- websecure
service: blackcat-editor
tls:
certResolver: letsencrypt
services:
blackcat-public:
loadBalancer:
servers:
- url: http://theblackcat-public:3923
blackcat-editor:
loadBalancer:
servers:
- url: http://theblackcat-editor:3923
Public traffic reached Traefik, but copyparty remained invisible to the host. TLS terminated at the proxy, and the upstreams were addressed by Docker service name rather than by an exposed port.
The Real-IP Trap
That was when a bug appeared that exists only when security is partially correct.
Traefik sent X-Forwarded-For containing the visitor's real address, but copyparty did not trust the proxy. Because every request seemed to originate from the same private Docker address, the anti-abuse protection eventually banned the proxy itself.
The result was a 403 for everyone and the message:
thank you for playing
The investigation showed that Traefik shared Gerbil's network namespace.
Instead of trusting the entire Docker subnet, I allowed only the exact /32 of the observed proxy address. For illustration, imagine Gerbil at 172.20.0.4:
[global]
xff-hdr: x-forwarded-for
xff-src: 172.20.0.4/32
rproxy: 1
rproxy: 1 documented that there was exactly one trusted proxy hop.
After restarting only the two copyparty containers, the proxy-wide ban disappeared. Logs began associating requests with the real visitor IP rather than with the Docker proxy address.
That fix came with an operational warning: the private address was dynamically assigned.
After recreating Gerbil or the Pangolin stack, I would need to verify the address again. The correct answer was not to trust 172.16.0.0/12 or the entire Docker network. The correct answer was to update the exact /32 to the newly observed proxy address.
I Did Not Want to Manage Users by Editing Files by Hand
At that point, the system worked, but it still depended on manual changes to YAML, authentication fragments, and volume configuration files.
That would have been tolerable for me alone and terrible for a community.
The next step was building an administrative CLI: theblackcat-admin.
The tool implemented an explicit lifecycle:
pending
โโโ approved
โ โโโ active
โ โโโ suspended โโ> active
โ โโโ archived
โโโ rejected
Each mutating operation uses:
- a lock;
- a backup;
- atomic writes;
- validation;
- rollback;
- an audit event written as JSON Lines.
The tool never accepts a plaintext password. The administrator generates a hash interactively with copyparty's own utility and installs only the resulting value.
A normal onboarding sequence looks like this:
theblackcat-admin candidate-add alice \
--display-name "Alice" \
--email "alice@example.net" \
--requested-quota 250
theblackcat-admin approve alice \
--quota 250 \
--approved-by pablo
theblackcat-admin credential-instructions alice
theblackcat-admin credential-install alice
theblackcat-admin activate alice \
--approved-by pablo
Activating a member creates:
- the member directory;
- the initial homepage;
- the editor volume;
- the public volume;
- the authentication entry;
- the public directory listing entry.
It then reloads accounts and volumes and validates the result.
Suspending a member performs the inverse without deleting anything. Archiving removes publication and authentication while preserving files.
I deliberately did not implement deletion. Removing data should be a separate, reviewed decision, not an accidental side effect of moderation.
Reloading Without Restarting the Service
Accounts and volumes are reloaded with USR1:
docker kill --signal=USR1 theblackcat-editor
docker kill --signal=USR1 theblackcat-public
This mattered because a member could be activated or suspended without recreating containers and without interrupting existing pages.
The CLI records StartedAt, sends the signal, checks container health, and confirms that the process did not restart.
Changes to the global configuration still require a restart, and I treated them as a separate class of operation. The ordinary member lifecycle does not touch global settings.
The Community Became More Than a Single /~pablo/
The final public layer was the community website itself.
I created:
- a homepage;
- an About page;
- a Members page;
- a Rules page;
- an Alpha Status page.
Everything uses local HTML and CSS, with no JavaScript, remote fonts, analytics, or trackers. robots.txt blocks indexing, and responses also carry noindex instructions.
The member page is not maintained by hand. The CLI generates it from protected member records and exposes only:
- username;
- public display name;
- public URL;
- a safe role label.
Email, quota, internal notes, suspension reasons, and timestamps never enter the public HTML.
Pablo appears as the founding administrator. An ordinary member appears only when their status is exactly active. Suspending or archiving someone regenerates the directory within the same transaction.
Backup: The Part That Never Looks Impressive in a Screenshot
I used Restic to create encrypted, deduplicated, testable snapshots.
The initial repository remained on the same virtual disk, outside the project tree. That provides rollback and recovery from human mistakes, but it is not disaster recovery.
I wrote that limitation in large letters because calling a same-disk copy an offsite backup would be dishonest.
The system received:
- daily snapshots;
- weekly maintenance;
- 7 daily snapshots retained;
- 4 weekly snapshots retained;
- 6 monthly snapshots retained;
- 1 yearly snapshot retained.
Maintenance runs forget with prune, followed by a repository check:
restic forget \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 6 \
--keep-yearly 1 \
--prune
restic check --read-data-subset=10%
I also wrote a real restore test. It restores the latest snapshot into a temporary directory, validates critical files, YAML, JSONL, Compose configuration, and permissions, securely removes restored secret files, and deletes the temporary tree.
The repository password lives outside the project in a root-only file.
Restic's encryption is valuable precisely because the password matters. Losing both the original machine and that password means losing access to the repository. That is why two operational tasks remain important:
- export the recovery key to a secure offline location;
- configure a genuinely independent offsite destination.
What the System Became
At the end of the ten-part build, I had something that did not exist as an off-the-shelf product: a copyparty-based webtilde with a community homepage, personal pages, an authenticated editor, read-only publication, quotas, moderation, auditing, backup, and a tested restore procedure.
| Layer | Function |
|---|---|
theblack.cat |
Public site, community pages, and /~username/ |
edit.theblack.cat |
Authenticated file management |
copyparty-editor |
Write access, quotas, nohtml, and account volumes |
copyparty-public |
index.html, h: *, noscript, and read-only mounts |
| Traefik | HTTPS, redirection, and routing |
theblackcat-admin |
Approval, activation, suspension, quota management, and auditing |
| Restic + systemd | Snapshots, retention, integrity checks, and restore tests |
The result is not a pubnix.
It does not offer SSH to members, personal cron jobs, compilers, or long-running processes. I do not hide that.
At the same time, calling it simple static hosting would also miss the point. It has a community, an identity, a URL structure, rules, limits, lifecycle management, and a shared publishing experience.
Where the Real Cleverness Was
The merit of the idea is not hidden in an obscure configuration line.
The clever part was realizing that a file server could occupy the role of a publishing panel, as long as I did not ask one single instance to act as both editor and public server.
Separating those responsibilities solved almost everything:
- the editor could require authentication and refuse to execute member HTML;
- the public service could deliver
index.htmlwithout knowing any credentials; - the same files appeared on both sides without a deployment stage;
- Docker enforced read-only publication in addition to application permissions;
- copyparty's volume model naturally became a
/~username/directory structure; - native quotas removed the need for a separate accounting system.
From that point on, the rest was edge engineering: integrating the proxy, trusting the correct client address, creating an administrative workflow, testing rollback, and documenting limitations honestly.
The idea opened the door. Rigor kept it from becoming a fragile toy.
The Decisions I Do Not Romanticize
Some choices remain compromises.
The local backup is on the same disk and still needs an offsite copy.
The administrative credential used during the build was exposed in conversation and must be rotated before opening invitations to outsiders.
The trusted private proxy address is dynamically assigned and must be revalidated after the stack is recreated.
Those pending items do not invalidate the project. They define its real state.
The Black Cat became operational in owner-only mode. I could edit, publish, test, and administer it. I still should not invite third parties until the credential is rotated.
That operational honesty is worth more than an artificial green badge.
What I Learned While Building It
- The best architecture appeared when I stopped trying to make one instance perform contradictory roles.
- On existing infrastructure, the initial audit saves more time than any automatic installer.
- Read-only at the application layer is not the same as read-only at the container layer; using both is better.
- Quotas without governance merely limit bytes. Human approval limits purpose.
- A badly configured proxy can turn anti-abuse protection into global downtime.
- Administrative automation needs transactions, locks, validation, and rollback even when the project is small.
- A backup deserves trust only after a restore has been tested.
- A tilde is as much a culture of publishing as it is a Unix machine. Part of that culture can survive without pretending shell access exists.
Conclusion
I began by asking whether copyparty could host simple websites.
I ended with a closed community for personal pages, built on top of a server that already did many important things and could not be treated as a disposable laboratory.
The most satisfying part was seeing that the idea remained simple even after it gained serious security and operational layers.
For a member, the experience is straightforward:
- log in to the editor;
- open their directory;
- change
index.html; - see the page at
/~username/.
Underneath that simple experience are separate containers, permissions, quotas, a reverse proxy, TLS, real-client-IP handling, auditing, backups, and restore tests.
The complexity ended up on the correct side of the door.
The Black Cat does not try to replace a traditional Unix tilde. It is another interpretation: a webtilde for people who want to build their own corner of the internet without beginning with SSH.
It was a genuinely good idea because it used exactly what copyparty does best โ files, accounts, and volumes โ to create something the project never explicitly promised to be, but for which it was surprisingly well prepared.
Member discussion