Skip to main content

Installing a Real Application

Published

2026-11-06

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:

sudo git clone https://github.com/mjfrigaard/baseball-trajectory.git /opt/baseball-trajectory
cd /opt/baseball-trajectory

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:

Table 1: Where a system install of baseball-trajectory puts things
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

baseball-trajectory File Locations

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-trajectory

It 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

baseball-trajectory Deployment on EC2

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.service

And 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.target

Four 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-trajectory

daemon-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:8000

Two 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:8000
HTTP/1.1 200 OK
date: Sat, 05 Sep 2026 18:23:10 GMT
server: uvicorn
content-type: text/html; charset=utf-8

A 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.

Four failures account for almost every bad first start, and each has a distinct signature in journalctl -u baseball-trajectory:

What the journal says What’s actually wrong
Failed at step EXEC ... No such file or directory The ExecStart path is wrong. Check it with ls /opt/baseball-trajectory/.venv/bin/shiny
ModuleNotFoundError: No module named 'baseball_trajectory' pip install -e . ran somewhere other than /opt/baseball-trajectory, or into a different venv
Address already in use Something already holds :8000, often a shiny run you started by hand and forgot
status=203/EXEC with no other output The venv was built from a Python that no longer exists at that path

The pattern in all four is the same: systemd executes a literal path with no shell, so anything the interactive session was quietly providing has to be spelled out in the unit file.

Recap

  1. A third party app gets its own tree under /opt: the source and a virtual environment built from the /opt Python, never the system copy.

  2. The install spreads across three locations by purpose: the app under /opt, the unit file under /etc/systemd/system, and logs in journald. baseball-trajectory keeps no state on disk, so it has no /var/lib entry, but it needs outbound HTTPS at install time and at runtime.

  3. Confirm the console script starts from the venv and answers on 127.0.0.1:8000 before handing it to systemd.

  4. The baseball-trajectory unit runs as its own service account (User=), starts from an absolute ExecStart path into the venv, binds to 127.0.0.1 so only nginx can reach it, and restarts on failure.

  5. After systemctl enable --now, confirm with systemctl status (active (running) and enabled) and curl -I http://127.0.0.1:8000, and read journalctl -u baseball-trajectory when either one fails.