An htpasswd file is a list of user:hash lines, read by Apache’s AuthUserFile and nginx’s auth_basic_user_file. It never holds the password itself: when a browser sends Basic credentials, the server hashes the password it received with the salt stored in the line and compares the results. Three formats work in both servers without help from the operating system. bcrypt ($2y$…) is the one to choose. apr1 ($apr1$…), the htpasswd command’s default, is Apache’s variant of MD5-crypt — 1,000 rounds of MD5 over the password, an 8-character salt and the previous digest — implemented here exactly as Apache’s apr_md5.c and nginx’s ngx_crypt.c do it, and checked against openssl passwd -apr1. {SHA} is base64 of one unsalted SHA-1, which nginx’s own documentation says should not be used.
Which server accepts what is less uniform than it looks. Apache checks $2a$ and $2y$ bcrypt, apr1 and {SHA} itself and passes anything else, $2b$ included, to the system crypt() — or on Windows compares it as plain text — so this writes $2y$, as Apache’s htpasswd does. nginx implements apr1, {SHA}, {SSHA} and {PLAIN} itself and passes everything else, bcrypt included, to the system crypt(): bcrypt works on Alpine’s musl and on systems whose crypt() comes from libxcrypt, and fails wherever crypt() lacks it. bcrypt also has a cost that a password database does not. Basic authentication sends the password with every request, so the server runs bcrypt on every request, and a page with 30 images runs it 30 times. That is why htpasswd -B defaults to cost 5, the default here too, well below the 10 OWASP asks for stored passwords.
The line format has traps of its own, and each is checked. A colon cannot appear in a username, because it ends the username both in the file and in the Basic credentials (RFC 7617). A username starting with # turns its line into a comment in both servers, so that user can never sign in. Line breaks are refused anywhere. The checker takes a line from an existing file — a trailing :comment is allowed, as both servers allow it — works out the format from its prefix, and says plainly when it is one it cannot check, such as DES crypt, SHA-crypt or {SSHA}, instead of calling it a mismatch. It also recognises the doubled $$ that Docker Compose files need, the usual reason a copied line fails.