The Black Cat

· PABLO'S DEVLOG


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_html happens 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:

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:

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:

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.

 1Internet
 2   |
 3   v
 4Traefik / HTTPS
 5   |
 6   +--> theblack.cat
 7   |       |
 8   |       +--> copyparty-public
 9   |               /w/sites      (read-only)
10   |               /w/community  (read-only)
11   |
12   +--> edit.theblack.cat
13           |
14           +--> copyparty-editor
15                   /w/sites      (read-write)
16                   /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:


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.

 1/srv/theblack-cat/
 2├── config/
 3│   ├── editor/
 4│   └── public/
 5├── sites/
 6├── community/
 7├── members/
 8├── secrets/
 9├── state/
10├── hists/
11├── administration/
12├── policies/
13├── audit/
14├── backup/
15├── scripts/
16└── release/

From the start, I treated configuration, content, runtime state, and secrets as different categories.

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:

The requested value would never become the approved value automatically.

I also set limits that made sense for personal websites:

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.

 1[/~example]
 2  /w/sites/example
 3
 4  accs:
 5    rwmd: example
 6
 7  flags:
 8    e2ds
 9    nohtml
10    xvol
11    vmaxb: 250m
12    vmaxn: 5k
13    sz: 1-50m
14    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.

 1[/~example]
 2  /w/sites/example
 3
 4  accs:
 5    h: *
 6
 7  flags:
 8    noscript
 9    xvol
10    norobots
11    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:


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:

An abbreviated Compose example looks like this:

 1services:
 2  editor:
 3    image: copyparty/ac@sha256:EXAMPLE_DIGEST
 4    user: "999:986"
 5    read_only: true
 6    restart: unless-stopped
 7    cap_drop:
 8      - ALL
 9    security_opt:
10      - no-new-privileges:true
11    pids_limit: 256
12    mem_limit: 1g
13    volumes:
14      - ./sites:/w/sites:rw
15      - ./community:/w/community:rw
16
17  public:
18    image: copyparty/ac@sha256:EXAMPLE_DIGEST
19    user: "999:986"
20    read_only: true
21    cap_drop:
22      - ALL
23    security_opt:
24      - no-new-privileges:true
25    volumes:
26      - ./sites:/w/sites:ro
27      - ./community:/w/community:ro
28
29networks:
30  pangolin:
31    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:

1HOME=/state
2XDG_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:

A simplified example:

 1http:
 2  routers:
 3    blackcat-public:
 4      rule: Host(`theblack.cat`)
 5      entryPoints:
 6        - websecure
 7      service: blackcat-public
 8      tls:
 9        certResolver: letsencrypt
10
11    blackcat-editor:
12      rule: Host(`edit.theblack.cat`)
13      entryPoints:
14        - websecure
15      service: blackcat-editor
16      tls:
17        certResolver: letsencrypt
18
19  services:
20    blackcat-public:
21      loadBalancer:
22        servers:
23          - url: http://theblackcat-public:3923
24
25    blackcat-editor:
26      loadBalancer:
27        servers:
28          - 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:

1thank 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:

1[global]
2  xff-hdr: x-forwarded-for
3  xff-src: 172.20.0.4/32
4  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:

1pending
2  ├── approved
3  │     └── active
4  │           ├── suspended ──> active
5  │           └── archived
6  └── rejected

Each mutating operation uses:

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:

 1theblackcat-admin candidate-add alice \
 2  --display-name "Alice" \
 3  --email "alice@example.net" \
 4  --requested-quota 250
 5
 6theblackcat-admin approve alice \
 7  --quota 250 \
 8  --approved-by pablo
 9
10theblackcat-admin credential-instructions alice
11
12theblackcat-admin credential-install alice
13
14theblackcat-admin activate alice \
15  --approved-by pablo

Activating a member creates:

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:

1docker kill --signal=USR1 theblackcat-editor
2docker 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:

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:

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:

Maintenance runs forget with prune, followed by a repository check:

1restic forget \
2  --keep-daily 7 \
3  --keep-weekly 4 \
4  --keep-monthly 6 \
5  --keep-yearly 1 \
6  --prune
7
8restic 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:

  1. export the recovery key to a secure offline location;
  2. 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:

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 #

  1. The best architecture appeared when I stopped trying to make one instance perform contradictory roles.
  2. On existing infrastructure, the initial audit saves more time than any automatic installer.
  3. Read-only at the application layer is not the same as read-only at the container layer; using both is better.
  4. Quotas without governance merely limit bytes. Human approval limits purpose.
  5. A badly configured proxy can turn anti-abuse protection into global downtime.
  6. Administrative automation needs transactions, locks, validation, and rollback even when the project is small.
  7. A backup deserves trust only after a restore has been tested.
  8. 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:

  1. log in to the editor;
  2. open their directory;
  3. change index.html;
  4. 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.

last updated: