sudo git clone https://github.com/mjfrigaard/baseball-trajectory.git /opt/baseball-trajectory
cd /opt/baseball-trajectoryInstalling a Real Application
Third party software follows the same convention as the interpreter in Install Apps: its own directory tree under /opt, not scattered across the filesystem. Here’s what that looks like for baseball-trajectory, a Shiny for Python app (source) that plots a player’s career arc from the MLB Stats API:
Create the virtual environment from the /opt Python (built in Building Python from source), not the system one, then install the package and its dependencies into it:
sudo /opt/python/3.12.1/bin/python3.12 -m venv /opt/baseball-trajectory/.venv
sudo /opt/baseball-trajectory/.venv/bin/pip install -e .This pulls the app’s dependencies from PyPI, so the instance needs outbound internet access. If the box is in a private subnet, verify the NAT gateway path works before you run this rather than after it hangs.
The install spreads across three locations, and each one follows the convention from Where do applications live? rather than piling everything into one folder:
| Location | What lives there |
|---|---|
/opt/baseball-trajectory |
Application source and the virtual environment |
/etc/systemd/system/baseball-trajectory.service |
The unit file telling systemd how to run it |
journald |
Application stdout and stderr, read with journalctl -u baseball-trajectory |
%%{init: {'theme': 'base', 'look': 'classic', 'themeVariables': {'fontFamily': 'monospace', "fontSize":"12px"}}}%%
graph TD
Root(["/"])
Opt("<strong>/opt/baseball-trajectory</strong>")
Venv("<strong>.venv/bin/shiny</strong><br/><strong>.venv/bin/baseball-trajectory</strong>")
Etc("<strong>/etc/systemd/system</strong>")
Unit("<strong>baseball-trajectory.service</strong>")
Log("<strong>journald</strong>")
Jctl("<em>journalctl -u baseball-trajectory</em>")
Root --"<em>application</em>"--> Opt --> Venv
Root --"<em>config</em>"--> Etc --> Unit
Root --"<em>logs</em>"--> Log --> Jctl
style Root fill:#d2562b,stroke:#fff,stroke-width:2px,color:#fff
style Opt fill:#e8a33d,stroke:#000,stroke-width:2px,color:#000,font-family:monospace
style Venv fill:#e8a33d,stroke:#000,stroke-width:1px,color:#000,font-family:monospace
style Etc fill:#2a6f77,stroke:#fff,stroke-width:2px,color:#fff,font-family:monospace
style Unit fill:#2a6f77,stroke:#fff,stroke-width:1px,color:#fff,font-family:monospace
style Log fill:#666,stroke:#999,stroke-width:2px,color:#fff,font-family:monospace
style Jctl fill:#666,stroke:#999,stroke-width:1px,color:#fff,font-family:monospace
Notice what’s missing: there’s no /var/lib entry. baseball-trajectory keeps nothing on disk. Rosters and career stats are fetched from https://statsapi.mlb.com and cached in memory, so a restart simply starts with an empty cache. That also means outbound HTTPS isn’t just an install time requirement; the running app needs it on every new player lookup.
Before handing it to systemd, confirm the console script starts from the venv:
/opt/baseball-trajectory/.venv/bin/baseball-trajectoryIt binds to 127.0.0.1:8000, so from a second SSH session curl -I http://127.0.0.1:8000 should return 200 OK.
Daemonizing with systemd
Now we turn baseball-trajectory into the kind of service described in Lab 10, running under the service account from Linux Users. Started by hand from an SSH session, it dies the moment that session closes. When the app runs as a service, systemd starts it on boot, restarts it after a crash, and collects its output. In front of the app, nginx terminates TLS; Configure SSL covers that setup.
%%{init: {'theme': 'base', 'look': 'classic', 'themeVariables': {'fontFamily': 'monospace', "fontSize":"12px"}}}%%
graph TD
Op(["Operator<br/>browser"])
Admin(["You<br/>SSH client"])
subgraph EC2["EC2 Instance"]
Nginx("<strong>nginx</strong><br/>:443 TLS")
App("<strong>baseball-trajectory</strong><br/>127.0.0.1:8000")
Sysd("<strong>systemd</strong> + <strong>journald</strong>")
end
MLB["MLB Stats API<br/>statsapi.mlb.com"]
Op --"HTTPS :443"--> Nginx
Admin --"SSH :22"--> EC2
Nginx --"HTTP :8000"--> App
App --"<em>HTTPS :443</em>"--> MLB
Sysd --"<em>supervises</em>"--> App
style EC2 fill:#1B2A41,stroke:#fff,stroke-width:2px,color:#fff
style Op fill:#d2562b,stroke:#fff,stroke-width:2px,color:#fff
style Admin fill:#d2562b,stroke:#fff,stroke-width:2px,color:#fff
style Nginx fill:#2a6f77,stroke:#fff,stroke-width:1px,color:#fff
style App fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff
style MLB fill:#666,stroke:#999,stroke-width:1px,color:#fff
style Sysd fill:#e8a33d,stroke:#000,stroke-width:2px,color:#000
Notice the one outbound arrow. The app pulls rosters and career stats from the MLB Stats API on demand, so the instance’s security group and any NAT path have to allow outbound HTTPS, or the app starts cleanly and then fails on the first player lookup.
Create the unit file:
sudo vim /etc/systemd/system/baseball-trajectory.serviceAnd give it this content:
[Unit]
Description=baseball-trajectory
After=network.target
[Service]
Type=simple
User=baseball-trajectory
WorkingDirectory=/opt/baseball-trajectory
ExecStart=/opt/baseball-trajectory/.venv/bin/shiny run baseball_trajectory.app:app --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetFour directives control how the service runs: User, ExecStart, --host, and Restart.
User=baseball-trajectory runs the process as the service account from Linux Users, not root. The app only serves requests and calls an external API, so that account needs no extra privilege at all.
ExecStart uses the absolute path into the venv. There’s no shell involved, no PATH to search, and no activation step. If this path is wrong the unit fails instantly, and it’s the single most common cause of a failed first start.
--host 127.0.0.1 binds the app to loopback only. Change it to 0.0.0.0 and the app is reachable from the internet without TLS and without the proxy, which is exactly what we don’t want.
Restart=on-failure with RestartSec=5 brings the app back after a crash, waiting five seconds between attempts so a fast crash loop doesn’t spin the CPU.
Load the new file and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now baseball-trajectorydaemon-reload tells systemd to reread the unit files on disk. enable registers the unit to start on boot, and --now starts it immediately so you don’t have to run start separately.
Confirming it actually came up
Never trust enable --now to mean the app is running. It means systemd tried.
sudo systemctl status baseball-trajectory● baseball-trajectory.service - baseball-trajectory
Loaded: loaded (/etc/systemd/system/baseball-trajectory.service; enabled; vendor preset: disabled)
Active: active (running) since Sat 2026-09-05 18:22:41 UTC; 12s ago
Main PID: 4812 (python3.12)
CGroup: /system.slice/baseball-trajectory.service
└─4812 /opt/baseball-trajectory/.venv/bin/python3.12 /opt/baseball-trajectory/.venv/bin/shiny run baseball_trajectory.app:app --host 127.0.0.1 --port 8000
Sep 05 18:22:41 ip-172-31-24-9 systemd[1]: Started baseball-trajectory.
Sep 05 18:22:42 ip-172-31-24-9 shiny[4812]: INFO: Started server process [4812]
Sep 05 18:22:42 ip-172-31-24-9 shiny[4812]: INFO: Uvicorn running on http://127.0.0.1:8000Two things to read here. Active: active (running) is the state, and enabled in the Loaded: line is the confirmation it will come back after a reboot. A unit can be running and not enabled, which works fine until the instance restarts.
Then confirm something is actually listening, from the instance itself:
curl -I http://127.0.0.1:8000HTTP/1.1 200 OK
date: Sat, 05 Sep 2026 18:23:10 GMT
server: uvicorn
content-type: text/html; charset=utf-8A 200 means the landing page rendered. A redirect is fine too. Connection refused means the process died between status and curl, and the journal will say why:
sudo journalctl -u baseball-trajectory -n 50 --no-pager-u filters to this unit, -n 50 limits it to the last 50 lines, and --no-pager dumps it straight to the terminal instead of opening less. When you want to watch a start attempt live, journalctl -u baseball-trajectory -f follows the log the same way tail -f does.
Recap
A third party app gets its own tree under
/opt: the source and a virtual environment built from the/optPython, never the system copy.The install spreads across three locations by purpose: the app under
/opt, the unit file under/etc/systemd/system, and logs injournald.baseball-trajectorykeeps no state on disk, so it has no/var/libentry, but it needs outbound HTTPS at install time and at runtime.Confirm the console script starts from the venv and answers on
127.0.0.1:8000before handing it tosystemd.The
baseball-trajectoryunit runs as its own service account (User=), starts from an absoluteExecStartpath into the venv, binds to127.0.0.1so only nginx can reach it, and restarts on failure.After
systemctl enable --now, confirm withsystemctl status(active (running)andenabled) andcurl -I http://127.0.0.1:8000, and readjournalctl -u baseball-trajectorywhen either one fails.