echo $PATHInstall Apps
Finding your applications
Installing an application and being able to run it are two different problems. A package manager like yum puts binaries somewhere the shell already knows to look, but anything I install by hand usually lands somewhere the shell doesn’t check. That’s when a command I know is installed comes back as command not found.
The PATH variable
PATH is an environment variable holding a colon separated list of directories. When I type a command, the shell walks that list from left to right and runs the first matching executable it finds. It never searches the whole filesystem.
Print it to see what the shell is actually checking:
/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/ec2-user/.local/bin:/home/ec2-user/binTo find out which copy wins, use which:
which python3/usr/bin/python3If a command isn’t found, the executable is almost always on the box, just not in one of those directories. Adding it is a matter of appending the directory to PATH in a startup file so the change survives a new login:
echo 'export PATH="/opt/python/3.12.1/bin:$PATH"' >> ~/.bashrc
source ~/.bashrcPutting the new directory first means it takes priority over anything with the same name further down the list.
%%{init: {'theme': 'base', 'look': 'classic', 'themeVariables': {'fontFamily': 'monospace'}}}%%
graph TD
Cmd(["shell command<br/><strong>python3</strong>"]) --> Check1("Check<br/><strong>/usr/local/bin</strong>")
Check1 -->|"not found"| Check2("Check<br/><strong>/usr/bin</strong>")
Check2 -->|"found!"| Run("Run<br/><strong>/usr/bin/python3</strong>")
Check1 -->|"found!"| Run2("Run<br/><strong>/usr/local/bin/python3</strong><br/>(first match wins)")
style Cmd fill:#d2562b,stroke:#fff,stroke-width:2px,color:#fff
style Check1 fill:#e8a33d,stroke:#000,stroke-width:2px,color:#000
style Check2 fill:#e8a33d,stroke:#000,stroke-width:2px,color:#000
style Run fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff
style Run2 fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff
Symlinks with ln
A symlink is a pointer: a small file that redirects to a real one somewhere else. Rather than adding a new directory to everyone’s PATH, I create a link inside a directory that’s already there.
The -s flag makes it symbolic, and the real path always comes first:
sudo ln -s /opt/python/3.12.1/bin/python3.12 /usr/local/bin/python3Confirm the link points where you expect:
ls -l /usr/local/bin/python3lrwxrwxrwx 1 root root 33 Aug 21 14:02 /usr/local/bin/python3 -> /opt/python/3.12.1/bin/python3.12%%{init: {'theme': 'base', 'look': 'classic', 'themeVariables': {'fontFamily': 'monospace'}}}%%
graph TD
User(["User types:<br/><code>python3</code>"])
Link("<strong>/usr/local/bin/python3</strong>")
Points("<strong>/opt/python/3.12.1/<br>bin/python3.12</strong>")
Real("<strong>Actual binary</strong>")
User --"Shell finds symlink"--> Link
Link --"Symlink points to"--> Points
Points --"Real executable runs"--> Real
style User fill:#d2562b,stroke:#fff,stroke-width:2px,color:#fff
style Link fill:#2a6f77,stroke:#fff,stroke-width:1px,color:#fff
style Points fill:#e8a33d,stroke:#000,stroke-width:1px,color:#000
style Real fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff
The other reason I reach for ln is versioning. When a new version of R or Python goes into /opt alongside the old one, repointing a single symlink switches every user over, and repointing it back is the rollback.
Where do applications live?
Linux spreads a single application across several directories by purpose, not by product. Knowing the convention is how I find a config file or a log without searching for it.
| Location | What lives there |
|---|---|
/bin, /usr/bin |
Core system executables, managed by the package manager |
/usr/local/bin |
Executables installed outside the package manager, plus symlinks |
/opt |
Self contained third party software, one directory per version |
/etc |
Configuration files |
/var/log |
Log files |
%%{init: {'theme': 'base', 'look': 'classic', 'themeVariables': {'fontFamily': 'monospace'}}}%%
graph TD
Root(["/"])
Bin("<strong>/bin</strong> or<br><strong>/usr/bin</strong>")
Local("<strong>/usr/local/bin</strong>")
Opt("<strong>/opt</strong>")
OptPy("<strong>/opt/python/3.12.1</strong>")
OptR("<strong>/opt/R/4.6.1</strong>")
Etc("<strong>/etc</strong>")
Log("<strong>/var/log</strong>")
Root --"<em>OS managed</em>"--> Bin
Root --"<em>symlinks here</em>"--> Local
Root --"<em>versions isolated</em>"--> Opt
Root --"<em>config files</em>"--> Etc
Root --"<em>log files</em>"--> Log
Opt --"<em>lib, bin, share</em>"--> OptPy
Opt --"<em>lib, bin, share</em>"--> OptR
style Root fill:#d2562b,stroke:#fff,stroke-width:2px,color:#fff,font-size:12px,font-family:monospace
style Bin fill:#2a6f77,stroke:#fff,stroke-width:1px,color:#fff,font-size:12px,font-family:monospace
style Local fill:#5b8c5a,stroke:#fff,stroke-width:1px,color:#fff,font-size:12px,font-family:monospace
style Opt fill:#e8a33d,stroke:#000,stroke-width:2px,color:#000,font-size:12px,font-family:monospace
style OptPy fill:#e8a33d,stroke:#000,stroke-width:1px,color:#000,font-size:12px,font-family:monospace
style OptR fill:#e8a33d,stroke:#000,stroke-width:1px,color:#000,font-size:12px,font-family:monospace
style Etc fill:#666,stroke:#999,stroke-width:1px,color:#fff,font-size:12px,font-family:monospace
style Log fill:#666,stroke:#999,stroke-width:1px,color:#fff,font-size:12px,font-family:monospace
/opt is the important one for data science work. Installing there keeps a version fully contained in its own folder, so nothing overwrites anything the operating system depends on.
Language versions
EC2 instances don’t ship with current versions of R or Python, so this section installs both on the instance we’ve SSH’d into.
Python version
The Python already on the instance belongs to the operating system. Amazon Linux 2 uses it for yum and other internal tooling, so upgrading it or installing packages into it with sudo pip install risks breaking the box itself.
The safer pattern is to install your own Python into /opt, leave the system copy alone, and create virtual environments from the /opt copy (the steps that build it are in Building Python from source below):
/opt/python/3.12.1/bin/python3.12 -m venv ~/my-app/venvActivate the virtual environment:
source ~/my-app/venv/bin/activateDeactivate the virtual environment with:
deactivateR version
R has no equivalent system dependency, so multiple versions can coexist in /opt/R without conflict. rig, the R Installation Manager, handles installing and switching between them.
rig isn’t in the Amazon Linux 2 repositories, so install it first. The portable Linux build is a tarball that unpacks into /usr/local, which puts rig in /usr/local/bin and on the PATH for every account:
curl -Ls https://github.com/r-lib/rig/releases/download/latest/rig-linux-x86_64-latest.tar.gz | \
sudo tar xz -C /usr/local
rig --versionOn a Graviton (arm64) instance, swap x86_64 for aarch64 in the URL. With rig installed, add R and make it the default:
sudo rig add 4.6.1Set it as the default:
sudo rig default 4.6.1Confirm the install; the asterisk marks the default version:
rig list* 4.6.1Your output may look different; newer rig versions print a table with a header row and more columns.
rig is one way to get R. On an Ubuntu machine (a local workstation, or an Ubuntu AMI instead of Amazon Linux), CRAN publishes its own apt repository, which tracks the current release (R 4.6.1 as of this writing). Add CRAN’s signing key and repository, then install r-base:
sudo apt update -qqInstall the tools add-apt-repository and key handling depend on:
sudo apt install --no-install-recommends software-properties-common dirmngrAdd CRAN’s signing key:
wget -qO- https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc | \
sudo tee -a /etc/apt/trusted.gpg.d/cran_ubuntu_key.ascAdd the CRAN repository for your Ubuntu release:
sudo add-apt-repository "deb https://cloud.r-project.org/bin/linux/ubuntu $(lsb_release -cs)-cran40/"Install R:
sudo apt install --no-install-recommends r-base$(lsb_release -cs) fills in the Ubuntu release codename. Pop!_OS is built on Ubuntu and reports the Ubuntu codename (noble on Pop!_OS 24.04), so the same commands work unchanged. CRAN currently builds for 26.04 (resolute), 24.04 (noble), and 22.04 (jammy).
Confirm the version:
R --version | head -1R version 4.6.1 (2026-06-24) -- "Happy Hop"This installs a single, system wide R under /usr/lib/R that apt upgrade keeps current. If you need several versions side by side, use rig instead; it works the same way on Ubuntu.
rig answers “which R is installed on this box?” It doesn’t pin the packages a project uses or the system libraries those packages compile against. rix covers that side. It writes a default.nix file describing a project’s R version, R packages, and system dependencies, and Nix builds that environment into /nix/store, isolated from both the system and anything rig installed.
| Tool | Scope | Lives in |
|---|---|---|
rig |
R versions for the whole instance | /opt/R/<version> |
rix + Nix |
R, packages, and system libraries for one project | /nix/store, described by default.nix |
Install Nix with the Determinate Systems installer, which the rix docs recommend. The extra configuration adds the rstats-on-nix binary cache, so R packages download prebuilt instead of compiling on the instance:
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | \
sh -s -- install --no-confirm --extra-conf "
trusted-users = root $USER
substituters = https://cache.nixos.org https://rstats-on-nix.cachix.org
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= rstats-on-nix.cachix.org-1:vdiiVgocg6WeJrODIqdprZRUrhi1JzhBnXv7aWI6+F0="Open a new shell so nix-shell and nix-build are on your PATH. The R that rig installed can generate the environment file, so install rix into it and describe the project:
mkdir -p ~/my-r-app && cd ~/my-r-app
Rscript -e 'install.packages("rix", repos = "https://cloud.r-project.org")'Rscript -e '
rix::rix(
r_ver = "4.6.1",
r_pkgs = c("dplyr", "shiny"),
system_pkgs = NULL,
git_pkgs = NULL,
ide = "none",
project_path = ".",
overwrite = TRUE
)'ide = "none" fits a server with no editor attached. rix() writes default.nix and an .Rprofile; the .Rprofile keeps R inside the Nix environment from reading the library paths of the rig installed R.
Build the environment, then enter it:
nix-build
nix-shellInside nix-shell, R and Rscript resolve to the Nix copy, not /opt/R:
which R
Rscript -e 'R.version.string'which R should print a path under /nix/store. exit leaves the shell and puts the rig default back in front. Commit default.nix with the project and anyone running nix-build gets the same R, packages, and system libraries, which also sidesteps the -devel packages below for anything Nix provides.
System libraries
Both languages depend on system libraries that neither one installs for you. A package that compiles from source will fail with a missing header error until the matching development package is present:
sudo yum install -y libcurl-devel openssl-devel libxml2-develThese are the three that account for most failed installs of curl, openssl, and xml2, and every R or Python package that depends on them.
%%{init: {'theme': 'base', 'look': 'classic', 'themeVariables': {'fontFamily': 'monospace'}}}%%
graph TD
SysPy["System Python<br/><em>owned by OS</em>"]
OptPy(["Your Python<br/><strong>/opt/python/3.12.1</strong><br/><em>isolated version</em>"])
Venv("Virtual Env<br/><strong>~/my-app/venv</strong><br/><em>project packages</em>")
SysPy -->|"leave untouched"| SysTools("yum, AWS CLI,<br/>OS internal tools")
OptPy -->|"create venv from"| Venv
Venv -->|"install packages to"| Packages("<strong>pip install<br/>my-packages</strong>")
R["System R<br/><em>usually absent</em>"]
OptR("Multiple R versions<br/><strong>/opt/R/4.6.1</strong><br/><strong>/opt/R/4.3.2</strong><br/><em>rig switches</em>")
Libs("System libraries<br/><strong>libcurl-devel</strong><br/><strong>openssl-devel</strong><br/><strong>libxml2-devel</strong>")
R -->|"alternative"| OptR
OptR --> Libs
Libs -->|"required for"| RPackages("R packages<br/>from source")
style SysPy fill:#d2562b,stroke:#fff,stroke-width:2px,color:#fff
style OptPy fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff
style Venv fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff
style Packages fill:#2a6f77,stroke:#fff,stroke-width:1px,color:#fff
style R fill:#d2562b,stroke:#fff,stroke-width:2px,color:#fff
style OptR fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff
style Libs fill:#e8a33d,stroke:#000,stroke-width:2px,color:#000
style RPackages fill:#2a6f77,stroke:#fff,stroke-width:1px,color:#fff
style SysTools fill:#2a6f77,stroke:#fff,stroke-width:1px,color:#fff
Building Python from source
The packaged Python is sometimes too old for what you need to run. Amazon Linux 2 ships with 3.7:
python3 --versionPython 3.7.16That’s the copy yum depends on, so upgrading it in place is off the table. The fix is the /opt pattern from above: build a second Python, install it into its own versioned directory, and leave the system one alone.
%%{init: {'theme': 'base', 'look': 'classic', 'themeVariables': {'fontFamily': 'monospace', "fontSize":"12px"}}}%%
graph TD
Check{"<strong>python3 --version</strong><br/>3.11 or newer?"}
Yes("Use it directly")
Build("Build into<br/><strong>/opt/python/3.12.1</strong>")
Venv("<strong>/opt/myapp/.venv</strong>")
Sys["<strong>/usr/bin/python3</strong><br/><em>3.7, owned by yum</em>"]
Check --"yes (AL2023, Ubuntu 22.04+)"--> Yes
Check --"no (Amazon Linux 2)"--> Build
Yes --> Venv
Build --"<em>python3 -m venv</em>"--> Venv
Sys --"<em>leave for</em>"--> Yum("yum, OS tooling")
style Check fill:#e8a33d,stroke:#000,stroke-width:2px,color:#000
style Yes fill:#5b8c5a,stroke:#fff,stroke-width:1px,color:#fff
style Build fill:#2a6f77,stroke:#fff,stroke-width:1px,color:#fff
style Venv fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff,font-family:monospace
style Sys fill:#d2562b,stroke:#fff,stroke-width:2px,color:#fff
style Yum fill:#666,stroke:#999,stroke-width:1px,color:#fff
Install the compiler and the development headers first, plus a few that Python needs for ssl, bz2, and ctypes support:
sudo yum groupinstall -y "Development Tools"sudo yum install -y openssl-devel bzip2-devel libffi-devel zlib-devel gitDownload and unpack the source:
cd /tmp
wget https://www.python.org/ftp/python/3.12.1/Python-3.12.1.tgz
tar xzf Python-3.12.1.tgz
cd Python-3.12.1Then configure it to land in /opt, and build:
sudo ./configure --prefix=/opt/python/3.12.1 --enable-optimizations
sudo make altinstall--prefix is what keeps the install self contained. altinstall is the flag that matters most: it installs python3.12 without also writing a python3 symlink that would shadow the system copy.
Confirm you got what you asked for:
/opt/python/3.12.1/bin/python3.12 --versionPython 3.12.1Recap
PATHdecides what the shell can find, andwhichtells you which copy is winning. Acommand not founderror usually means a directory is missing from the list, not that the software is missing.Install language runtimes into
/optand symlink them into/usr/local/bin. That keeps the system Python untouched, makes the version visible to every account and service, and turns a version change into a one line edit.When the packaged interpreter is too old, build your own into
/optwithmake altinstallrather than touching the system copy. Installing a Real Application applies the same pattern to a full app.