644, digit by digit
6 is 4+2 — read plus write, no execute — for the owner. 4 is read alone for the group and for everyone else. There is no execute bit anywhere in the mode, which is the whole argument for using it on content: a file that cannot be executed cannot be executed by accident either.
64 of the 512 three-digit modes have no execute bit anywhere. That is the neighbourhood 644 lives in, and it is where a served file, a template, a stylesheet and a JSON fixture all belong. The execute bit is for programs and for directories, and static content is neither.
The four properties a served file needs, and what satisfies all of them
Take the requirements one at a time. The owner must be able to read the file back and to edit it. A worker process that is neither the owner nor in the owning group must be able to read it. Nobody except the owner should be able to write it. And nothing should be executable. Those four constraints, applied to the whole 512-mode space, leave exactly 2 survivors:
| Mode | Symbolic | Owner | Group | Others |
|---|---|---|---|---|
| 604 | rw----r-- | read, write | nothing | read |
| 644 | rw-r--r-- | read, write | read | read |
The survivors differ in exactly one bit: whether the owning group may read the file. 644 says yes, 604 says no. Nothing else in the 512 qualifies — which is a more useful thing to know than "644 is the standard", because it tells you there is no cleverer option you have overlooked. If you want the group locked out as well, 604 is the mode, and it is unusual enough that you should leave a comment next to it in your deployment script.
644 against its neighbours
The modes people actually consider for content files, and what each one changes. The columns are computed from the bits:
| Mode | Symbolic | Worker can read | Group can write | World can write | Executable |
|---|---|---|---|---|---|
| 644 | rw-r--r-- | yes | no | no | no |
| 664 | rw-rw-r-- | yes | yes | no | no |
| 666 | rw-rw-rw- | yes | yes | yes | no |
| 640 | rw-r----- | no | no | no | no |
| 600 | rw------- | no | no | no | no |
| 755 | rwxr-xr-x | yes | no | no | yes |
The row worth staring at is 666. It is often reached for when a file "needs to be writable", and what it actually does is let every account on the machine rewrite the contents — including any process that gets a foothold through an unrelated service. If a web application genuinely needs to write to a path, give the file to the application's user and keep it at 600 or 640; opening it to every account is never the fix.
Still 403 at 644? Check the directories
A 403 with a correct 644 on the file is almost always a directory problem. The kernel resolves a path component by component, and each component needs the search bit; the file's own mode is consulted last. Apache names this precisely in its log — the message is quoted from its source below — and the diagnostic is one command:
namei -l /var/www/html/index.html That prints the mode and ownership of every component from the root down, so the failing one is visible at a glance instead of being guessed at. Fix the directories with 755 and leave the file at 644.
What breaks a 644 file
644 is safe for content and wrong for three specific things. A shell script at 644 gives you exit status 126 and a "Permission denied" that has nothing to do with the filesystem being read-only. A Git hook at 644 is silently skipped — Git tests the execute bit and never reads the shebang, and prints at most a one-line advisory. And an SSH private key at 644 is refused outright by the client, because its mask is 077 rather than 022. Those three rows, from the projects' own source:
| What you see | Where it comes from | Fires on | Fix |
|---|---|---|---|
./deploy.sh: Permission denied (exit status 126) Any POSIX shell (bash, dash, zsh, sh) · stderr, plus exit status 126 shown for mode 644 on a script you are trying to run, e.g. ./deploy.sh verbatim, quoted from the standard: | POSIX.1-2024 (IEEE Std 1003.1-2024) — XCU §2.8.2 Exit Status for Commands · Shell Command Language Linux kernel — include/uapi/asm-generic/errno-base.h · EACCES / EPERM | specified by POSIX, with no single expression guarding it: the command name resolves but is not an executable utility. Each shell implements it its own way described, not quoted — the message is not guarded by one expression 512 of the 4096 modes fire it. Assumes that the caller is the owner, which is the usual case for a script in your own checkout. Otherwise read the group or other digit. | chmod +x ./deploy.sh least-privilege mode 755 |
hint: The '.git/hooks/pre-commit' hook was ignored because it's not set as executable. Git · stderr, as advice during commit/push shown for mode 644 on .git/hooks/pre-commit and friends
verbatim format string: | Git — hook.c · find_hook() Git — advice.c · vadvise() find_hook() in hook.c passes the format above to advise(); vadvise() in advice.c splits it on newlines and prints each line through fprintf(stderr, _("%shint:%s%.*s%s\n"), …), so every line reaches the terminal with a "hint: " prefix that is not part of the format string. | found_hook = access(path.buf, X_OK) >= 0; … if (!found_hook) { if (errno == EACCES && advice_enabled(ADVICE_IGNORED_HOOK)) quoted verbatim from the source 512 of the 4096 modes fire it. | chmod +x .git/hooks/pre-commit least-privilege mode 755 |
Permissions 0644 for '/home/deploy/.ssh/id_ed25519' are too open. OpenSSH client (ssh, scp, sftp, git over ssh) · stderr, before the connection is attempted shown for mode 644 on ~/.ssh/id_ed25519 (a private key)
verbatim format string: | OpenSSH (portable) — authfile.c · sshkey_perm_ok() | (st.st_uid == getuid()) && (st.st_mode & 077) != 0 quoted verbatim from the source 4032 of the 4096 modes fire it. | chmod 600 /home/deploy/.ssh/id_ed25519 least-privilege mode 600 |
shell-exit-126 — The wording of the message before the exit status is the shell’s own and varies — bash prints "bash: ./deploy.sh: Permission denied". What is specified, and therefore what you can rely on in a script, is the 126.
git-hook-ignored — The advice is printed at most once per hook name per process, and only when errno is EACCES — a hook that is missing entirely produces nothing at all. If you are grepping a log for this, grep for the text after the prefix: the "hint: " comes from the advice printer, not from the message.
ssh-privkey-too-open — The check only runs when the key is owned by the user running ssh. A key owned by somebody else is not mode-checked at all — the comment in the source says so explicitly. Note also that the printed number is st_mode & 0777, so a set-user-ID bit on the key never appears in the message.
Going the other way — from 644 up to an executable mode — the relative form is
chmod a+x file, which touches only the execute bits
and leaves the read and write bits exactly as they were.