A permission error is a question about identity and access, not an instruction to make everything writable. A web application should usually read its release code, read only the configuration it needs, and write to a small set of deliberate data directories.
This guide targets an Ubuntu test server with GNU coreutils and a named application account. You need sudo for the account setup and cross-user checks. The shell blocks were syntax-checked; the complete permissions model must be rehearsed on your own test server before applying it to a live deployment. Follow the VPS learning path if this is your first service.
Understand files and directories separately
Every ordinary file has an owner, a group and access bits for owner, group and others. A mode of 0640 gives its owner read/write, its group read, and others no access. 0750 additionally gives owner and group execute permission.
For a directory, execute means traversal: accessing a known entry through it. Read permits listing names. Write together with traversal permits modifying directory entries, which can include deleting a file even when that file is not writable. A secret is therefore not protected only by its own mode; the path leading to it matters.
id
ls -ld /srv /srv/demo /srv/demo/releases
namei -l /srv/demo/releases/release-001/server.mjs
stat -c '%A %a %U:%G %n' /srv/demo/releases/release-001/server.mjsReplace the final path with an existing sample release. namei walks the path component by component, helping expose a parent directory that the runtime account cannot traverse. The GNU permission reference covers access bits and special modes.
Define a small ownership plan
Use a service account that is not your administrator. The following creation commands are for a fresh lab where these names and paths do not already exist:
getent passwd demo-api
getent group demo-api
sudo adduser --system --group --home /var/lib/demo-api demo-api
sudo install -d -o root -g demo-api -m 0750 /srv/demo-api
sudo install -d -o root -g demo-api -m 0750 /srv/demo-api/releases
sudo install -d -o root -g demo-api -m 0750 /srv/demo-api/releases/release-001
sudo install -d -o demo-api -g demo-api -m 0750 /var/lib/demo-api/uploadsNo result from the two getent queries means the name was not found. If it exists, stop and investigate instead of taking ownership of another application's account. install -d sets explicit directory ownership and modes; it is not a safe way to overwrite an unknown existing directory's policy without inspection.
/srv/demo-api/releases/release-001/ root:demo-api, code read-only to app
/var/lib/demo-api/uploads/ demo-api:demo-api, writable app data
/etc/demo-api.conf root:root, private systemd configurationUse your deployment account or a controlled deployment step to install code. The runtime should not ordinarily replace its own executable files. Framework caches may need an additional writable directory; configure that explicitly rather than making the whole project writable.
Apply permissions to exact files
After preparing a reviewed server.mjs in your working directory, install only that file:
sudo install -o root -g demo-api -m 0640 \
server.mjs /srv/demo-api/releases/release-001/server.mjs
sudo stat -c '%A %a %U:%G %n' \
/srv/demo-api/releases/release-001/server.mjsA JavaScript source file run through /usr/bin/node needs read permission, not an executable bit. A directly executed shell script normally needs executable permission and a valid interpreter line. Package files and native executables have different requirements; preserve intentional modes rather than applying chmod 644 recursively to everything.
chmod u=rw,g=r,o= expresses the same basic access bits as 0640. Numeric and symbolic forms are both useful. What matters is the exact intended change, not which spelling is fashionable. Never use chmod 777 as a diagnostic shortcut: it changes the security boundary while obscuring the cause.
Test as the actual runtime user
sudo -u demo-api test -r /srv/demo-api/releases/release-001/server.mjs
printf 'Code read check: %s\n' "$?"
sudo -u demo-api test -w /srv/demo-api/releases/release-001/server.mjs
printf 'Code write check, expected nonzero: %s\n' "$?"
sudo -u demo-api test -w /var/lib/demo-api/uploads
printf 'Uploads write permission check: %s\n' "$?"Expect success for reading code and writable uploads, and failure for writing code. A successful test -w does not prove a write will succeed: a read-only mount, quota, full disk or mandatory-access policy may still prevent it. Rehearse a harmless create/read/delete cycle through the application in its isolated test data directory.
Root-owned 0600 configuration can be read by the system systemd manager and passed to a service through EnvironmentFile=. The app does not need direct read access in that model. Conversely, an app that opens the file itself needs a different deliberate policy. Environment variables are not a secret vault; avoid logging them. See systemd execution settings.
Diagnose the next layer and retain a rollback
If ordinary modes look correct, inspect the service's actual User, supplementary groups, working directory and sandbox settings. ProtectHome can intentionally hide files under home directories; ReadWritePaths does not override Unix permission checks. AppArmor and mount options are separate layers. Inspect relevant denied-operation logs without disabling those protections wholesale.
Before changing an existing file, record its owner, group and mode in a private change note. Restore those exact attributes if the change fails; do not recursively reset an entire release. Group membership changes also require new processes or sessions to take effect. Test the service after a controlled restart, not only your still-open administrator shell.
Continue with systemd environment diagnosis, then deployment behind Nginx. An operational handover from CodeKits can include these ownership boundaries and a tested recovery checklist.
