When you run multiple services on a shared Linux server, one of the most common DevOps challenges is giving a teammate or a client access to exactly one running service: nothing more, nothing less. You don't want to hand them root access, you don't want them browsing other application directories, and you don't want them accidentally taking down unrelated containers.

This article walks through a practical, real-world approach to scoped Docker container access on a shared Linux server. By the end, you will have a second Linux user who logs in and lands directly inside a specific Docker container, with visibility into the app's configuration files and real-time logs: and no access to anything else on the server.

The Setup

We have a Linux server running several Docker Compose stacks. One of those stacks is a payment service. A developer needs access to that payment service: they need to inspect the running container, read the environment configuration, and tail logs in real time. They should not have access to other stacks or host-level system files.

What we will do:

  1. Verify the user exists on the server
  2. Add them to the Docker group
  3. Create a restricted login shell that drops them into the target container
  4. Set the correct directory permissions so they can read the app config files
  5. Verify everything works end to end

Step 1: Check If the User Already Exists

Before creating a new user, check whether one has already been provisioned:

grep -iE 'myapp-dev' /etc/passwd

Enter fullscreen mode Exit fullscreen mode

What this does:

  • grep searches for a pattern in a file
  • -i makes the search case-insensitive
  • -E enables extended regular expressions (useful if you want to match patterns like myapp|otherapp)
  • /etc/passwd is the file Linux uses to store user account information: every user on the system has a line here

Example output:

myapp-dev:x:1002:1002:,,,:/home/myapp-dev:/bin/bash

Enter fullscreen mode Exit fullscreen mode

Each field separated by : means:

  • myapp-dev: username
  • x: password is stored in /etc/shadow (hashed)
  • 1002: user ID (UID)
  • 1002: primary group ID (GID)
  • ,,,: optional comment/GECOS field (name, phone, etc.)
  • /home/myapp-dev: home directory
  • /bin/bash: login shell

If the user does not exist, create them:

useradd -m -s /bin/bash myapp-dev
passwd myapp-dev

Enter fullscreen mode Exit fullscreen mode

  • useradd: creates a new user account
  • -m: creates a home directory at /home/myapp-dev
  • -s /bin/bash: sets bash as the default shell
  • passwd myapp-dev: sets a password for the user interactively

Step 2: Check the User's Current Groups

id myapp-dev

Enter fullscreen mode Exit fullscreen mode

What this does:
id prints the user ID, primary group ID, and all supplementary groups a user belongs to.

Example output:

uid=1002(myapp-dev) gid=1002(myapp-dev) groups=1002(myapp-dev),1003(myapp)

Enter fullscreen mode Exit fullscreen mode

This tells you the user exists but is not yet in the docker group, meaning they cannot interact with Docker at all yet.

Step 3: Add the User to the Docker Group

usermod -aG docker myapp-dev

Enter fullscreen mode Exit fullscreen mode

What this does:

  • usermod: modifies an existing user account
  • -a: append (do not replace existing groups)
  • -G docker: add the user to the docker supplementary group

Why this matters: Docker's socket (/var/run/docker.sock) is only accessible to root and members of the docker group. Without this step, the user cannot run any docker commands at all.

Security note: Adding a user to the docker group effectively gives them root-equivalent access to the host via Docker (e.g. they could mount the host filesystem). For a trusted developer in a dev environment this is acceptable. In production, consider rootless Docker or more granular controls.

Verify the group was added:

id myapp-dev

Enter fullscreen mode Exit fullscreen mode

You should now see docker in their groups:

uid=1002(myapp-dev) gid=1002(myapp-dev) groups=1002(myapp-dev),998(docker),1003(myapp)

Enter fullscreen mode Exit fullscreen mode

Step 4: Create a Restricted Login Shell

Instead of giving the user a full bash session on the host, we create a small shell script that immediately drops them into the target container when they log in.

cat > /usr/local/bin/myapp-shell << 'EOF'
#!/bin/bash
exec docker exec -it my-payment-container /bin/sh
EOF

Enter fullscreen mode Exit fullscreen mode

What this does:

  • cat >: writes the following content into a file
  • << 'EOF' ... EOF: a heredoc block; everything between the two EOF markers is written as-is to the file
  • #!/bin/bash: shebang line; tells the OS to run this script with bash
  • exec: replaces the current shell process with the docker exec command (no parent shell remains)
  • docker exec: runs a command inside an already-running container
  • -i: keeps STDIN open (interactive mode)
  • -t: allocates a pseudo-TTY (gives you a proper terminal experience)
  • my-payment-container: the name of your target container (get this from docker ps)
  • /bin/sh: the shell to open inside the container (use /bin/bash if the container image has bash installed)

Make the script executable:

chmod +x /usr/local/bin/myapp-shell

Enter fullscreen mode Exit fullscreen mode

  • chmod: changes file permissions
  • +x: adds the execute bit, making it runnable as a program

Alternative shells you could use: If the container has bash, replace /bin/sh with /bin/bash for a better experience. Some minimal images (Alpine-based) only have /bin/sh.

Step 5: Set the Restricted Shell as the User's Login Shell

usermod -s /usr/local/bin/myapp-shell myapp-dev

Enter fullscreen mode Exit fullscreen mode

  • usermod: modifies the user account
  • -s: sets the login shell
  • /usr/local/bin/myapp-shell: the script we just created

Now whenever myapp-dev logs in via SSH or su, instead of getting a bash prompt on the host, they are immediately placed inside the container.

Verify the change:

grep myapp-dev /etc/passwd

Enter fullscreen mode Exit fullscreen mode

The last field should now show:

myapp-dev:x:1002:1002:,,,:/home/myapp-dev:/usr/local/bin/myapp-shell

Enter fullscreen mode Exit fullscreen mode

Step 6: Give the User Visibility Into the App Directory

By default the user's home directory is /home/myapp-dev. The actual application files (.env, docker-compose.yml, logs) live in a different directory, for example /home/apps/my-payment-service. We need to:

  1. Give the user's group read access to that directory
  2. Change their home directory so they land there on login

Set group ownership and permissions:

chown root:myapp /home/apps/my-payment-service
chmod 750 /home/apps/my-payment-service
chmod g+r /home/apps/my-payment-service/.env

Enter fullscreen mode Exit fullscreen mode

  • chown: changes file/directory ownership
  • root:myapp: sets owner to root, group to myapp (the group myapp-dev already belongs to)
  • chmod 750: owner gets read/write/execute, group gets read/execute, others get nothing
  • g+r: adds read permission for the group on the .env file specifically

Change the user's home directory:

If the user has an active session, kill it first:

pkill -u myapp-dev

Enter fullscreen mode Exit fullscreen mode

  • pkill: sends a signal to processes matching a criterion
  • -u myapp-dev: matches all processes owned by myapp-dev
  • This sends SIGTERM by default, gracefully terminating their session

Then update the home directory:

usermod -d /home/apps/my-payment-service myapp-dev

Enter fullscreen mode Exit fullscreen mode

  • -d: sets the user's home directory

Now when myapp-dev logs in they land directly at /home/apps/my-payment-service and can see docker-compose.yml, .env, and any log directories.

Step 7: Verify End to End

Switch to the user and test:

su - myapp-dev

Enter fullscreen mode Exit fullscreen mode

  • su: substitute user (switch to another user)
  • -: loads the full login environment for that user (home directory, shell, environment variables)

If the login shell is set to the container script, you will land directly inside the container:

/ $

Enter fullscreen mode Exit fullscreen mode

If the login shell is set to bash, you will land in the app directory:

myapp-dev@server:/home/apps/my-payment-service$ ls -a
.  ..  .env  docker-compose.yml  live-logs

Enter fullscreen mode Exit fullscreen mode

From there, the user can also interact with Docker normally:

# See running containers in this stack
docker compose -f docker-compose.yml ps

# Tail real-time logs
docker logs -f my-payment-container

# Shell into the container manually
docker exec -it my-payment-container /bin/sh

Enter fullscreen mode Exit fullscreen mode

What Both Users Share

Once this is set up, both root and myapp-dev are operating on the same running containers. This means:

  • docker compose ps: both see identical container states
  • docker logs -f my-payment-container: both stream the same real-time log output simultaneously
  • docker exec -it my-payment-container /bin/sh: both can open shells inside the container at the same time
  • Any restart or config change made by either user affects the same running service

This is the intended behaviour for a shared dev environment: one running stack, multiple people with visibility into it.

Understanding the Two Access Modes

Depending on how you configure the login shell, you get two different access patterns:

Mode Login Shell Where They Land What They See
Container-only /usr/local/bin/myapp-shell Inside the container filesystem Container files only, no host files
Host directory scoped /bin/bash App directory on the host .env, docker-compose.yml, logs, and Docker CLI

For a developer who needs to read config, tail logs, and inspect the compose setup: the host directory scoped mode is more practical. For a user who only needs to run commands inside the application process: the container-only mode is more restrictive and appropriate.

Quick Reference: All Commands

# Check if user exists
grep -iE 'myapp-dev' /etc/passwd

# Create user if needed
useradd -m -s /bin/bash myapp-dev
passwd myapp-dev

# Check current groups
id myapp-dev

# Add to docker group
usermod -aG docker myapp-dev

# Create restricted container shell
cat > /usr/local/bin/myapp-shell << 'EOF'
#!/bin/bash
exec docker exec -it my-payment-container /bin/sh
EOF
chmod +x /usr/local/bin/myapp-shell

# Set as login shell
usermod -s /usr/local/bin/myapp-shell myapp-dev

# Or set bash as login shell (host access mode)
usermod -s /bin/bash myapp-dev

# Set app directory permissions
chown root:myapp /home/apps/my-payment-service
chmod 750 /home/apps/my-payment-service

# Kill active session before modifying home dir
pkill -u myapp-dev

# Change home directory
usermod -d /home/apps/my-payment-service myapp-dev

# Test login
su - myapp-dev

Enter fullscreen mode Exit fullscreen mode

Conclusion

With a few targeted usermod commands, a wrapper shell script, and careful directory permissions, you can give a developer or client scoped access to a specific Docker service running on a shared server: without handing them the keys to everything else.

This pattern is particularly useful in multi-tenant dev environments where multiple application stacks share a single droplet or VM and you need to delegate access per service without spinning up separate servers or managing complex ACLs.

For production environments, consider extending this pattern with:

  • Rootless Docker: each user runs their own Docker daemon with no shared socket
  • Sudoers rules: restrict which Docker commands a user can run via sudo
  • Audit logging: use auditd to log all Docker exec sessions for the scoped user

Thanks for reading...