Docker 101
1. Why containers
A container is a program running in its own small, isolated environment on your server, packaged with everything it needs. You get a web app, a database, a media server or a home-automation hub running with a couple of commands, without installing its dependencies on the host and without one app's libraries breaking another's. Remove the container and the host is as clean as before.
meshTerm's Containers tab (a Pro feature) finds the engines on your hosts and shows their containers, stacks, images, volumes and networks on your phone, so you can start, stop, read logs and deploy. This page is the background: what you are looking at, and how to set a server up well. When something does not work, Troubleshooting: Containers maps each message to a fix.
2. Docker, Podman and friends
The program that actually runs containers is called an engine. They are all built on the same open container standards, so an image that works with one usually works with the others, and their command lines look alike.
| Engine | What it is | Good to know |
|---|---|---|
| Docker | The original and the most widely documented. A background service (dockerd) plus the docker command. | Most guides and ready-made compose files assume it. On a Mac you use Docker Desktop, or a lighter alternative such as OrbStack or Colima, which run Linux in a small virtual machine. |
| Podman | A Docker-compatible engine with no always-running daemon. Containers are ordinary processes of the user who started them. | Runs rootless by default, which is a security advantage. The podman command accepts the same options as docker. Common on Fedora and Red Hat family systems. On a Mac it uses podman machine. |
| nerdctl | A Docker-like command for containerd, the engine underneath Docker and Kubernetes. | Useful on hosts that already run containerd. Its compose support needs the compose file named compose.yaml (or one of the other standard names) at the top of the project. |
| Apple container | Apple's own tool, container, for running Linux containers on a Mac with Apple silicon, each in a lightweight virtual machine. | Mac only. Start its service with container system start. |
If you are not sure which to pick for a Linux server: Docker if you want to follow the widest range of tutorials, Podman if you prefer the safer rootless default. meshTerm supports all of them.
3. Images, containers and stacks
- An image is a read-only template: an application and its files, downloaded from a registry such as Docker Hub (
nginx,postgres:16). Think of it as the installer. - A container is a running (or stopped) instance made from an image. You can run several containers from one image. Think of it as the installed, running program. A container's own files are thrown away when it is removed, which is why data goes in volumes.
- A stack (Docker Compose calls it a project) is a group of containers that belong together, described in one file, usually
compose.yaml. A typical stack is a web app plus its database. meshTerm shows these as Stacks and can deploy one from a template or a Git repository.
The basic commands (use podman in place of docker for Podman; add sudo if your user is not allowed to use the engine):
docker pull nginx # download an image
docker run -d --name web -p 8080:80 nginx # start a container from it
docker ps # what is running
docker logs web # what it printed
docker stop web && docker rm web # stop it and remove the container
And a minimal stack. Save it as compose.yaml, then run docker compose up -d in that folder (docker compose down stops and removes it):
services:
web:
image: nginx
ports:
- "127.0.0.1:8080:80"
volumes:
- site:/usr/share/nginx/html
restart: unless-stopped
volumes:
site:
This uses Compose v2, the docker compose command (with a space). The old Compose v1 was a separate docker-compose command (with a hyphen); it is no longer maintained, and meshTerm's stacks need v2.
4. Ports and volumes
Ports
A container has its own network. To reach a service inside it, you publish a port: -p 8080:80 means "connect to port 8080 on the host and land on port 80 in the container". The left number is the one you choose and the one that must be free on the host; the right number is what the application listens on.
-p 8080:80publishes on every network address of the host, including the public one. See security.-p 127.0.0.1:8080:80publishes only on the host itself. Reach it from the phone with an SSH port forward, which is what meshTerm's in-app web forwarding does, or over Tailscale.- Rootless engines cannot publish ports below 1024 (see rootless).
Volumes
A container's own storage disappears with it. A volume keeps data outside the container, so you can update or recreate the container without losing it.
- A named volume is managed by the engine:
-v pgdata:/var/lib/postgresql/data. Simplest, and the engine sets the permissions. - A bind mount maps a host folder in:
-v /srv/site:/usr/share/nginx/html. You see the files directly on the host, but the owner and permissions of that folder must suit the user inside the container.
Back up important volumes before you remove them. Removing a container never removes its named volumes unless you ask; deleting a volume (or docker system prune --volumes) deletes the data for good.
5. Installing an engine
You need a server you can sign in to, and an account that can install software: root, or a user with sudo. The commands use sudo; as root, leave it out. On a host with no sudo, wrap a command in su -c '...'. Fresh Debian and Ubuntu servers need apt-get update before any install (the commands below include it), and fresh servers often have no curl, which the convenience script needs.
The distro package comes from your operating system's own repository: simple, updated with the rest of the system, and often a little behind Docker's newest release. Docker's convenience script (get.docker.com) adds Docker's own repository and installs the current Docker Engine, the CLI and the Compose plugin. It works on Ubuntu, Debian, Raspberry Pi OS, Fedora, RHEL and CentOS, not on Alpine or macOS. Docker describes it as a convenience for getting going quickly; for a server you depend on, installing from Docker's repository by hand is their recommendation. You can read the script before running it: curl -fsSL https://get.docker.com -o get-docker.sh, then sh get-docker.sh --dry-run shows what it would do.
Ubuntu
# Distro package, with Compose v2
sudo apt-get update && sudo apt-get install -y docker.io docker-compose-v2
# Or Docker's convenience script (install curl first on a fresh server)
sudo apt-get update && sudo apt-get install -y curl
curl -fsSL https://get.docker.com | sh
# Or Podman, no daemon
sudo apt-get update && sudo apt-get install -y podman
Debian
# Distro package
sudo apt-get update && sudo apt-get install -y docker.io
# Or Docker's convenience script (install curl first on a fresh server)
sudo apt-get update && sudo apt-get install -y curl ca-certificates
curl -fsSL https://get.docker.com | sh
# Or Podman
sudo apt-get update && sudo apt-get install -y podman
Debian's own Compose package is the old v1, so for stacks use Docker's script (it installs the Compose v2 plugin) or Docker's repository. On Debian 12 the dockerd program lives in /usr/sbin, which is not on the PATH of an SSH command, so a tool that looks for it by name may not find it even though the service is running. Debian 11 is end of life and its repositories can fail, so use a supported release.
Fedora
# Podman is Fedora's usual choice
sudo dnf install -y podman
# Docker Engine from Fedora's own packages, with Compose
sudo dnf install -y moby-engine docker-compose
sudo systemctl enable --now docker
# Or Docker's convenience script
curl -fsSL https://get.docker.com | sh
Alpine
# As root (a fresh Alpine has no sudo, curl or bash)
apk add docker docker-cli-compose
rc-update add docker default
rc-service docker start
addgroup $USER docker # optional: lets that user run docker (read the security section first)
Alpine uses OpenRC rather than systemd, so services start with rc-service. The convenience script does not support Alpine. For Podman: apk add podman.
macOS
# Docker Desktop
brew install --cask docker-desktop
open -a Docker
# Or OrbStack (lighter, fast)
brew install --cask orbstack
open -a OrbStack
# Or Colima, a command-line Docker host
brew install colima docker docker-compose
colima start
# Or Podman
brew install podman
podman machine init && podman machine start
# Or Apple container (Apple silicon): install from Apple's releases, then
container system start
If you do not have Homebrew, install it from brew.sh first. There is no convenience script on a Mac. Docker Desktop has its own licence terms for larger companies; the others are alternatives. meshTerm 2.3 lists engines on Mac hosts too.
Check it works
sudo docker run --rm hello-world # Docker
podman run --rm docker.io/library/hello-world # Podman (full name; see below)
sudo docker compose version # should say v2.x
Podman needs the registry in the image name (docker.io/library/hello-world) because it does not assume Docker Hub the way Docker does; see Image Name Needs a Registry. When it works, add the host in meshTerm if you have not already, and open the Containers tab. If the host shows up under "Not Checked" or "no container engine", Troubleshooting: Containers explains why.
6. Rootful and rootless
This is the most useful idea for running containers safely.
- Rootful (the usual Docker setup): the engine runs as
root. Starting a container needs root's power, so your user must be in thedockergroup or usesudo. Containers can use any port, and the service starts at boot. - Rootless: the engine and its containers run as your own, ordinary user. "Root" inside a container is mapped to an unprivileged range of IDs, so a container that is compromised does not hold root on the host. You need no
dockergroup and no sudo. Podman works this way by default; Docker has a rootless mode too.
meshTerm labels an engine "(rootless)" or "(rootful)", and offers Use Rootless Docker Instead or Use Rootless Podman Instead where that can work.
What rootless costs
- No ports below 1024 for an unprivileged user. Use 8080 instead of 80, or see the caveat under low ports.
- Setup: it needs subordinate user and group IDs (
/etc/subuidand/etc/subgid) and thenewuidmapandnewgidmaphelpers. A few images expect real root-owned files and need adjusting. - It stops when you sign out, unless you turn on lingering (below). The Fleet shows an advisory line for this.
- Networking goes through a user-space helper (
pastaorslirp4netns), a little slower than the rootful bridge.
Rootless Podman
# Debian, Ubuntu: uidmap supplies newuidmap and newgidmap
sudo apt-get update && sudo apt-get install -y podman uidmap
# Ubuntu 24.04 and newer only: passt gives faster rootless networking
sudo apt-get install -y passt
# Fedora: no uidmap package. newuidmap and newgidmap come from shadow-utils (already installed)
sudo dnf install -y podman passt
# every system: check your user has subordinate ID ranges, and add them if empty
grep "^$USER:" /etc/subuid /etc/subgid
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
# let meshTerm and Compose talk to it, and keep it running after you sign out
systemctl --user enable --now podman.socket
sudo loginctl enable-linger $USER
passt is packaged only for Ubuntu 24.04 and newer and for Fedora. On Ubuntu 22.04 or Debian 11, apt will say "Unable to locate package passt"; leave it out and Podman uses slirp4netns for networking.
User ID ranges
A rootless engine maps the users inside a container onto a range of user and group IDs that your user owns on the host. Those ranges live in /etc/subuid and /etc/subgid, one line per range in the form name:start:count. Most images need the full 65,536 IDs a Linux system uses.
- No ranges: Podman can run almost no image. meshTerm offers one step that adds a free, non-overlapping range to both files and then runs
podman system migrate. That stops the user's running containers, so start them again afterwards. - Too small: the user has ranges, but they add up to fewer than 65,536 IDs, so images that use higher IDs fail to unpack. meshTerm never changes ranges that are already there; the server's admin enlarges them as below. You can hide this advisory for the host with Don't Show Again.
To enlarge a user's ranges safely, as an administrator, add a second range for the user and leave the one they have where it is:
- List every range in use. A range covers
starttostart + count - 1, and no two may overlap, in either file. - Pick a free start that is above every range listed and above every real user and group ID in use on the machine. Accounts from a directory service (LDAP, SSSD, FreeIPA) get IDs from that service's own ranges, often in the hundreds of thousands or millions, and they do not appear in
/etc/passwd, so check the ranges it is configured with too. - Add a line for the user with that start and a count of 65,536 to both files, after the user's existing line, which stays first. The counts add up.
- As that user, run
podman system migrate. Their running containers stop; start them again.
Do not change the start of a range the user already has. Their existing containers, images and volumes are stored with files owned through the old mapping. Move it and that rootless storage breaks; the way out is podman system reset, which deletes all of that user's containers, images and volumes.
# 1. every range in use (name:start:count)
cat /etc/subuid /etc/subgid
# 2. shadow's usermod ADDS a range (the user's existing line stays first):
# a free block of 65,536 above every range listed and every real ID in use
sudo usermod --add-subuids 1000000-1065535 --add-subgids 1000000-1065535 alice
# 2. Alpine: BusyBox has no usermod; install shadow's, then use the line above
sudo apk add shadow
# 2. Or edit both files as root: keep the existing line, add one after it
# alice:100000:1000 (existing, unchanged)
# alice:1000000:65536 (added)
# 3. as that user (stops their running containers)
podman system migrate
Rootless Docker
# Debian, Ubuntu prerequisites
sudo apt-get update && sudo apt-get install -y uidmap dbus-user-session curl
# Fedora: shadow-utils already provides the helpers
# Run as the normal user, not root. Docker's installer for rootless mode:
curl -fsSL https://get.docker.com/rootless | sh
# It prints the lines to add to ~/.bashrc, for example:
export PATH=$HOME/bin:$PATH
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
systemctl --user enable --now docker
sudo loginctl enable-linger $USER # keep it running after you sign out
If a system-wide (rootful) Docker is also installed, stop it first so the two do not collide: sudo systemctl disable --now docker.service docker.socket. Docker's own guide, "Run the Docker daemon as a non-root user (Rootless mode)", has the details for other systems.
7. Security
None of this is a reason to avoid containers. These are the handful of facts that decide how safe your server is. meshTerm's own messages are short on purpose and point here for the detail.
The docker group is root
Being a member of the docker group is equivalent to being root on that host. The Docker daemon runs as root and will start whatever container a group member asks for, including one that mounts the host's whole filesystem. So adding a user to the group gives them everything sudo would, without sudo's password prompt and without its log of who ran what. The same is true of sudo docker and of rootful Podman's socket.
That is how Docker is designed, not a bug. On a personal server where your one account is already the administrator, it is a normal and reasonable trade. It matters when the account is shared, when a service or an agent (including the one meshTerm signs in as) is not meant to hold full control of the machine, or when you run images you did not build. In those cases prefer rootless, or require a sudo password.
When rootless makes sense
- The SSH account is not meant to be an administrator.
- You run third-party images or experiments and want a compromised container to stop at your user's own files.
- The host has more than one user.
- You are happy to give up ports below 1024 and to enable lingering.
If you need ports 80 and 443 and the full feature set, a rootful engine with a carefully limited account is also a fine setup. The point is to choose on purpose.
Passwordless sudo: keep the rule narrow
meshTerm can use a sudo password that stays on your device in the Keychain. That grants nothing on the host by itself, so it is the safer choice. If you would rather have no password, allow only the engine program for only that user, never ALL, and edit with visudo so a typo cannot lock you out. Remember that passwordless access to a rootful engine is still root access, as the app tells you.
command -v docker # note the full path
sudo visudo -f /etc/sudoers.d/meshterm-docker
# one line, your username, one command:
alice ALL=(root) NOPASSWD: /usr/bin/docker
Do not expose the Docker socket or daemon over TCP
- The Docker socket (
/var/run/docker.sock) is full control of the host. Do not mount it into a container unless that container is trusted as much as root. meshTerm warns: "Mounts the container engine's socket. It can control every container on this host, the same as root." - Never start the daemon listening on
tcp://0.0.0.0:2375. That port has no authentication: anyone who can reach it is root on your server, and exposed Docker ports are found and abused quickly. If you must have a remote API, use TLS with client certificates (port 2376), or just use SSH. meshTerm reaches the engine over your SSH connection and does not need the daemon on the network.
Published ports are reachable from the network (and Docker can bypass ufw)
-p 8080:80 opens port 8080 on every address of the host, usually including the internet-facing one. On a host with ufw, this surprises people: Docker adds its own firewall rules that run before ufw's, so sudo ufw deny 8080 does not hide a published port. Rootless engines publish through a normal user-space listener, so the firewall applies to them as usual.
- Bind to the host only. Publish as
-p 127.0.0.1:8080:80(in Compose:"127.0.0.1:8080:80"). Then nothing outside the host can connect. - Reach it safely. Use meshTerm's web forwarding (it opens a local port forward over SSH and shows the page in the app), an SSH tunnel, or Tailscale. You can also publish on the host's tailnet address only, for example
-p 100.64.0.5:8080:80. - Only publish what must be public, and put a reverse proxy with HTTPS in front of it. To filter published ports with a firewall, put rules in Docker's
DOCKER-USERchain, which Docker runs first. - In your cloud provider's firewall or security group, open only the ports you mean to.
Keep images updated, and prefer official ones
- An image is software with its own bugs. Pull fresh versions and recreate the containers:
docker compose pull && docker compose up -d(meshTerm can pull the latest image and recreate a container). - Prefer Docker Official Images and Verified Publisher images on Docker Hub, or the project's own registry. Be careful with an image from an unknown account, and check what it needs mounted.
- Pin a version tag (
postgres:16) instead of relying onlatest, so an update is a decision, then update on purpose. - Keep the host patched too (
unattended-upgradeson Debian and Ubuntu,dnf-automaticon Fedora), and remove images you no longer use. - Where an image allows it, run it as a non-root user and give it only the volumes it needs.
Secrets: use the Vault, and keep .env files out of git
- In meshTerm's container and stack forms, turn on Keep in Vault for a password or token. It stores the value on your device and sends it only when the container is created. Be aware of where it goes next: a value that becomes an environment variable is visible to anyone who can inspect the container, and a value for a stack is written to the stack's
.envfile on the host. The app says so before you deploy. The app also flags passwords written into a compose file ("Move it to Environment to keep it out of the compose file"). - Never commit secrets. Keep
.envout of git, commit an.env.examplewith empty values, and restrict the real file:echo ".env" >> .gitignore chmod 600 .env git rm --cached .env # if it was already committed (it stays in history: rotate the secrets) - If a secret did reach a repository, treat it as exposed and replace it. Deleting the file does not remove it from history.
Low ports and ip_unprivileged_port_start
Rootless engines cannot publish ports below 1024 because Linux reserves them for root. You can lower the limit, but the setting is host-wide: once it is, say, 80, every user and program on the machine can listen on ports from 80 up, not only your containers. On a single-user server that may be fine; on a shared one it is not. Prefer a port of 1024 or higher (8080), or a reverse proxy on 80 and 443.
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/99-unprivileged-ports.conf
8. Next steps
- Troubleshooting: Containers: every message in the Containers tab, with fixes per system.
- Fresh-server notes: why a new server lacks sudo, curl or python3, and what to run.
- User Guide: hosts, persistent sessions, agents, SFTP, port forwarding and more.
- Tailscale Setup Guide: reach services on your server without opening any port.