Skip to content

feat: rootless container & small improvements#1373

Closed
MrRubberDucky wants to merge 2 commits intoFuzzyGrim:devfrom
MrRubberDucky:rootless-container
Closed

feat: rootless container & small improvements#1373
MrRubberDucky wants to merge 2 commits intoFuzzyGrim:devfrom
MrRubberDucky:rootless-container

Conversation

@MrRubberDucky
Copy link
Copy Markdown

This PR makes container run as a rootless user by default. Fixes #1120 and replaces PR #1128

Following files were modified:

  • supervisord.conf
  • nginx.conf
  • Dockerfile
  • entrypoint.sh
  • /src/config/settings.py

Changes

Container now runs as user & group abc:abc with UID & GID of 1000, similar to aforementioned PR. It also takes care of permission inside the container, ensuring that abc user can write to directories it needs. All logs get written to /tmp as rootless user can't access /dev/stdout, /dev/stderr - supervisor writes stdout and stderr of apps to /tmp/appname-stdout---supervisord-randomstring.log so I suppose it's not that big of an issue.

There's one problem and it's with nginx trying to write an /var/lib/nginx/logs/error.log all the time but it just seems to be a hard-coded value and any errors are still logged to supervisord stdout log files if they occur. Since this isn't considered fatal, I see it as a pointless thing to try to fix.

Added USE_X_FORWARDED_HOST to /src/config/settings.py as otherwise I couldn't get django to trust Caddy's X Forwarded For header. This will be more useful for people that will reverse proxy their Yamtrack container to the web and want SSO to work properly. Without it, SSO redirect gets built wrongly and instead of the domain from the headers, it used LAN IP address for redirect, causing invalid callback URL error.

Modified nginx.conf to listen on both IPv4 and IPv6 by default. Currently the Dockerfile creates another IPv6 configuration file then uses an undocumented environment variable to switch between them, which seems pointless considering that nginx supports dual-stack connections just fine. Additionally, all relevant temp and log files are written to /tmp to allow it to run rootless without barrage of permission errors or having to constantly mount everything to be owned under current user.

entrypoint.sh was reduced to just two simple commands: the simple migration python command and supervisord exec.

PGID and PUID are unused variables now.

Potential problems arising from this PR

SQLite installs with bind volumes will need to chown their volumes to be owned by rootless user inside container, otherwise they will get either out of memory (happens when SQLite can't write to the directory, or to the database), or errors such as access denied. User ownership doesn't matter for any other directory as the permissions are allowing enough with them being r-x.

Did you test it?

Yup. It works fine, nothing else to say really.

What's the benefit?

You can now run the container as any rootless user and as read only, as long as you fix relevant directory ownership yourself. This gets rid of the convenience aspect but in turn it increases security as container init and processes running under it are ran via non-root user with strict permissions that prevent modification to entrypoint.sh and anything copied from /src/*

If you will be using external database, you can run it as read only easily without suffering through permission errors. Here's my current Quadlet as an example that runs as my user with randomized user namespace and read only.

[Unit]
Description=Yamtrack - self hosted media tracker for movies, tv shows, anime, manga, video games, books, comics, and board games.
Requires=postgres-yamtrack.service
Requires=valkey-yamtrack.service
After=postgres-yamtrack.service
After=valkey-yamtrack.service

[Install]
WantedBy=default.target

[Service]
Restart=on-failure
SystemCallArchitectures=native
MemoryDenyWriteExecute=false

[Container]
Image=localhost/yamtrack:test
ContainerName=yamtrack
EnvironmentFile=%h/Environments/Yamtrack/.env
AutoUpdate=registry
DropCapability=ALL
NoNewPrivileges=true
ReadOnly=true
Network=Yamtrack.network
# Internal networks
Network=Postgres-Yamtrack.network
Network=Valkey-Yamtrack.network
PublishPort=9103:8000
User=%U:%U
UserNS=auto:size=1002

...and no, this wasn't written by LLM before anyone asks.

Hopefully this is descriptive enough to explain what was changed. All container logs from processes are accessible within container in `/tmp` now so no easy direct logs in stdout.

Dockerfile:
- Create an user abc:abc with UID and GID of 1000
- Change ownership of directories to abc:abc and set more restrictive file & directory permissions
- Remove shadow package after installation
- Drop sed -i command for nginx IPv6, instead it now runs as dual-stack (it works fine in my setup, though I can revert this change if desired)
- Run python manage.py collectstatic --noinput as abc:abc user instead of root
- Container runs entrypoint as abc:abc user (1000:1000)

supervisord.conf:
- Remove user elevation so supervisord doesn't panic on launch
- Remove /dev/stderr, /dev/stdout from log directives as it needs root privileges to attach to them
- Save temporary supervisord logs to /tmp
- Remove IPv6 environment variable from nginx command

nginx.conf:
- Make nginx write it's logs and files to /tmp to avoid permission denied issues (supervisord still redirects output from console to /tmp)
- Listen on both `:8000` and `[::]:8000`

src/config/settings.py
- Added `USE_X_FORWARDED_HOST = True` as otherwise django was not trusting Caddy's headers after reverse proxying, breaking SSO auth flow by setting callback URL to local IP address.
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request transitions the container to run as a non-root user, moves permission management from the entrypoint script to the Dockerfile, and updates Nginx and Supervisor configurations for rootless operation. Several critical issues were identified, including a syntax error in the Supervisor Nginx command and the redirection or removal of log outputs, which would make logs inaccessible via standard container logging tools. Additionally, the unconditional IPv6 configuration in Nginx may cause startup failures in some environments, and the security implications of enabling USE_X_FORWARDED_HOST should be carefully reviewed.

Comment thread supervisord.conf Outdated
Comment on lines 6 to 7
command=nginx -c $CONF -g "daemon off;"'
priority=1
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The nginx command contains an undefined variable $CONF and a trailing single quote, which will cause the process to fail to start. Additionally, removing stdout_logfile and stderr_logfile settings prevents logs from appearing in docker logs. Even in rootless containers, processes can typically write to their own stdout/stderr. If permission issues occur with device files, using /proc/self/fd/1 and /proc/self/fd/2 is a better alternative than writing to temporary files in /tmp.

command=nginx -c /etc/nginx/nginx.conf -g "daemon off;"
priority=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

Comment thread nginx.conf
user abc;
worker_processes auto;
worker_processes auto;
error_log /tmp/error.log;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Redirecting error_log to a file in /tmp makes it invisible to the container engine's logging system (e.g., docker logs). It is recommended to keep this pointing to /dev/stderr. If permission issues occur in rootless mode, consider using /proc/self/fd/2.

error_log /dev/stderr warn;

Comment thread nginx.conf
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;

access_log /tmp/access.log;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Redirecting access_log to a file in /tmp makes it invisible to the container engine's logging system. It is recommended to keep this pointing to /dev/stdout. If permission issues occur in rootless mode, consider using /proc/self/fd/1.

    access_log  /dev/stdout  main;

Comment thread supervisord.conf
Comment on lines 10 to 11
command=gunicorn --control-socket /tmp/gunicorn.ctl --config python:config.gunicorn config.wsgi:application
user=abc
priority=5
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Removing stdout_logfile and stderr_logfile prevents logs from appearing in docker logs. It is recommended to restore these settings pointing to /dev/stdout and /dev/stderr (with maxbytes=0) for all programs in this configuration.

command=gunicorn --control-socket /tmp/gunicorn.ctl --config python:config.gunicorn config.wsgi:application
priority=5
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0

Comment thread supervisord.conf
[supervisord]
nodaemon=true
user=root
logfile=/tmp/supervisord.conf
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The log file for supervisord is named supervisord.conf, which is confusing as it shares the same name as the configuration file. It should ideally be named supervisord.log.

logfile=/tmp/supervisord.log

Comment thread nginx.conf
server {
listen 8000;
listen [::]:8000;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Adding listen [::]:8000; unconditionally might cause Nginx to fail to start on systems where IPv6 is disabled. The previous implementation used a conditional check to handle this. Consider making this optional or ensuring the environment supports IPv6.

Comment thread src/config/settings.py
if BASE_URL:
CSRF_COOKIE_PATH = BASE_URL + "/"

USE_X_FORWARDED_HOST = True
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Enabling USE_X_FORWARDED_HOST can be a security risk as it allows clients to spoof the Host header, which Django uses to construct absolute URLs. Ensure that your upstream proxy (like Nginx or Caddy) is configured to strictly set or strip this header before passing it to the application.

@MrRubberDucky
Copy link
Copy Markdown
Author

MrRubberDucky commented Apr 23, 2026

Ah the clanker had a field trip here, we truly live in the future. Not gonna resolve these (as I don't really know how and I don't wanna accidentally nuke this PR with LLM changing stuff 'cus it misunderstood what I've said) as they're non-issues and are done the way they are due to supervisord logic doing funky things while trying to access /dev/stdout / /dev/stderr that makes it error out with EACCES on rootless user, I may know that's the case because I've tried it 😆

IPv6 is generally fine anyways as rootless networks do both IPv4 and IPv6 even if it's disabled on host and any IPv4 connection is properly resolved because nginx & rootful / rootless networking is just clever like that. It could be reverted though at the request of a maintainer, or changed back to how it was, I don't mind.

This would also replace #1264 as I set entrypoint.sh permissions here to 555.

Edit: I've been thinking if it's worthwhile dumping logs to /tmp/logs instead, or finding out how nginx rootless image does it if logs being displayed on docker logs is an important feature to have. I'll look into it in case it's wanted.

...in supervisord.conf. Undefined variable voes. nginx probably ignored it and tried it's own directory even if it was empty, otherwise I don't understand why it wouldn't fail here.
@MrRubberDucky MrRubberDucky closed this by deleting the head repository Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Running Yamtrack as non-root and read-only with docker

1 participant