Install splam

Published

2026-09-07

WarningCaution

This section is being revised. Thank you for your patience.

This isn’t a chapter covered in DO4DS, but I decided to add it because it’s a ‘real world’ example of deploying a Python application on AWS. The application is called splam, or ‘Shiny for Python Linux Admin Management.’ You can view it’s source code here: https://github.com/mjfrigaard/splam

This chapter will walk through how to deploy the splam application in an AWS EC2 instance. It will cover the topics we’ve touched on in previous chapters (and include other things to consider).

What the app needs from the host

splam is a Shiny for Python app that shells out to systemctl and journalctl. Every button in the UI ends up as a subprocess call against the service manager on the machine the app is running on.

That constraint decides the whole deployment. The app can’t run in a container, because the systemd it would see is the container’s, not the host’s. It can’t run behind Lambda, because there’s no host to manage. It has to run directly on the box it administers, which on AWS means an EC2 instance, daemonized with systemd, with something in front of it terminating TLS.

Here’s the shape of what we’re building:

%%{init: {'theme': 'base', '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"]
        Splam["<strong>splam</strong><br/>127.0.0.1:8000"]
        Sysd[("<strong>systemd</strong> + <strong>journald</strong>")]
    end

    Op --"HTTPS :443"--> Nginx
    Admin --"SSH :22"--> EC2
    Nginx --"HTTP :8000"--> Splam
    Splam --"<em>systemctl / journalctl</em>"--> Sysd
    Sysd --"<em>supervises</em>"--> Splam

    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 Splam fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff
    style Sysd fill:#e8a33d,stroke:#000,stroke-width:2px,color:#000

splam Deployment on EC2

Notice the loop at the bottom. systemd supervises splam, and splam controls systemd. That’s fine, right up until you use the app to restart the splam unit itself and wonder why your browser session died.

The instance security group we set up back in AWS EC2 needs exactly two inbound rules for this deployment:

  • 22/tcp from your own IP or VPN range, never 0.0.0.0/0
  • 443/tcp from wherever operators connect

Do not open 8000/tcp. The app binds to 127.0.0.1, so it isn’t reachable from outside the instance anyway, but leaving the port closed means a misconfigured bind address can’t quietly expose an admin console to the internet.

Getting a modern Python onto the instance

splam needs Python 3.11 or newer. Amazon Linux 2 ships with 3.7:

python3 --version
Python 3.7.16

That’s the copy yum depends on, so upgrading it in place is off the table for the reasons covered in Install Apps. The fix is the same /opt pattern from that chapter: build a second Python, install it into its own versioned directory, and leave the system one alone.

%%{init: {'theme': 'base', '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/splam/.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
    style Sys fill:#d2562b,stroke:#fff,stroke-width:2px,color:#fff
    style Yum fill:#666,stroke:#999,stroke-width:1px,color:#fff

Which Python Runs the App

Install the compiler and the development headers first. These are the same libraries from Install Apps, plus a few Python needs specifically for ssl, bz2, and ctypes support:

sudo yum groupinstall -y "Development Tools"
sudo yum install -y openssl-devel bzip2-devel libffi-devel zlib-devel git

Download 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.1

Then 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 --version
Python 3.12.1

--enable-optimizations runs the profile guided build, which roughly triples compile time. On a t3.micro that’s 20 to 30 minutes, and the box will be pegged the whole time. Drop the flag if you just want a working interpreter and don’t care about a few percent of runtime speed.

If make dies partway through with an out of memory error, that’s the 1 GB of RAM on a t3.micro, not a broken source tree. Either size up temporarily (see Instance Size) or add swap.

Installing splam into /opt

The app itself follows the same convention as the interpreter. It’s third party software with its own directory tree, so it goes in /opt/splam.

Clone the repository:

sudo git clone https://github.com/mjfrigaard/splam.git /opt/splam
cd /opt/splam

Create the virtual environment from the /opt Python, not the system one:

sudo /opt/python/3.12.1/bin/python3.12 -m venv /opt/splam/.venv

Then install the package and its dependencies into that environment:

sudo /opt/splam/.venv/bin/pip install -e .

This pulls Shiny for Python and its transitive dependencies from PyPI, so the instance needs outbound internet access. If you put the box in a private subnet, verify the NAT gateway path works before you run this rather than after it hangs.

When it finishes, two commands exist inside the venv:

ls /opt/splam/.venv/bin | grep -E 'shiny|splam'
shiny
splam-chat
splam-users

Activating a virtual environment is just a PATH edit for the current shell. It doesn’t survive logout, and systemd never runs a login shell at all, so a unit file that says ExecStart=shiny run ... will fail with command not found no matter how many times activation worked interactively.

The habit that avoids the whole class of problem is calling the venv binaries by their absolute path: /opt/splam/.venv/bin/shiny, /opt/splam/.venv/bin/splam-users. That’s what goes in the unit file, and it’s what I use in every command below.

Where splam keeps its files

The install spreads across four locations, and each one follows the convention from Install Apps rather than piling everything into one folder.

Table 1: Where a system install of splam puts things
Location What lives there
/opt/splam Application source and the virtual environment
/var/lib/splam Credentials file and the audit trail
/etc/systemd/system/splam.service The unit file telling systemd how to run it
journald Application stdout and stderr, read with journalctl -u splam

By default splam writes its credentials and audit trail to ~/.local/share/splam/, which is the right answer for a laptop and the wrong answer for a service. A daemon’s home directory is an implementation detail of whatever account it happens to run as, and audit records that move when the service account changes aren’t much of an audit trail.

The SPLAM_DATA_DIR environment variable moves them somewhere durable:

sudo mkdir -p /var/lib/splam

/var/lib is the conventional location for state a service owns and expects to persist across restarts. The app creates the directory mode 0700 if it has to, but making it explicitly up front means the first run doesn’t fail for a reason unrelated to the app.

%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace', "fontSize":"12px"}}}}%%

graph TD
    Root(["/"])
    Opt("<strong>/opt/splam</strong>")
    Venv("<strong>.venv/bin/shiny</strong><br/><strong>.venv/bin/splam-users</strong>")
    Var("<strong>/var/lib/splam</strong>")
    Creds("<strong>credentials.json</strong><br/><strong>audit_trail.json</strong>")
    Etc("<strong>/etc/systemd/system</strong>")
    Unit("<strong>splam.service</strong>")
    Log("<strong>journald</strong>")
    Jctl("<em>journalctl -u splam</em>")

    Root --"<em>application</em>"--> Opt --> Venv
    Root --"<em>state</em>"--> Var --> Creds
    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
    style Venv fill:#e8a33d,stroke:#000,stroke-width:1px,color:#000
    style Var fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff
    style Creds fill:#5b8c5a,stroke:#fff,stroke-width:1px,color:#fff
    style Etc fill:#2a6f77,stroke:#fff,stroke-width:2px,color:#fff
    style Unit fill:#2a6f77,stroke:#fff,stroke-width:1px,color:#fff
    style Log fill:#666,stroke:#999,stroke-width:2px,color:#fff
    style Jctl fill:#666,stroke:#999,stroke-width:1px,color:#fff

splam File Locations

Creating the first account

splam has no default account, which means a fresh install has a login screen nobody can get past. Create an admin before you daemonize anything, and set SPLAM_DATA_DIR on the command so the credentials land where the service will look for them:

sudo SPLAM_DATA_DIR=/var/lib/splam /opt/splam/.venv/bin/splam-users add admin
Password:
Repeat for confirmation:
Created user 'admin' (role: admin) in /var/lib/splam/credentials.json

The password is salted and hashed, not stored. List what exists at any point:

sudo SPLAM_DATA_DIR=/var/lib/splam /opt/splam/.venv/bin/splam-users list
admin     admin

There’s a second role worth knowing about. An auditor account can read Status, Logs, Tasks, and the Audit Trail, but can’t start, stop, or restart anything:

sudo SPLAM_DATA_DIR=/var/lib/splam \
  /opt/splam/.venv/bin/splam-users add qa-reviewer --role auditor

If you skip the SPLAM_DATA_DIR prefix, the account gets written to /root/.local/share/splam/ and the service will start with an empty credentials file. Nothing errors, and the symptom is a login screen that rejects a password you know is correct.

Privilege to control services

systemctl start, stop, and restart all require privilege. This is the part of the deployment worth slowing down on, because the app authenticates fine without it and then fails at the moment you actually need it.

Run it as root for a first deploy and every action works. That’s also the widest possible blast radius: anyone who gets past the login screen can restart anything on the box.

Running the unit as root is the fastest way to a working deployment, and it’s what I’d do to confirm the plumbing works end to end. It is not what I’d leave in place.

The tighter option is a dedicated service account created with sudo useradd -r -s /sbin/nologin splam, then a scoped rule written with sudo visudo -f /etc/sudoers.d/splam listing only the units operators are allowed to touch, one line per command:

splam ALL=(root) NOPASSWD: /bin/systemctl restart cron, /bin/systemctl status cron

Then change User=splam in the unit file and hand the account ownership of /var/lib/splam with chown. The tradeoff is real: every new unit an operator needs is another entry in that file. Decide which side of it you want to live on before you hand out logins, not after.

Whatever you decide, Status and Logs work at any privilege level. It’s only the action buttons that need rights.

Daemonizing with systemd

Running shiny run in an SSH session works until the session ends. Making it a systemd unit is what turns it into something that starts on boot and comes back after a crash, which is the whole point of the daemonizing discussion in Lab 10.

Create the unit file:

sudo vim /etc/systemd/system/splam.service

And give it this content:

[Unit]
Description=splam
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/splam
Environment=SPLAM_DATA_DIR=/var/lib/splam
ExecStart=/opt/splam/.venv/bin/shiny run splam.app:app --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Four lines in there are doing the work worth understanding.

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.

Environment=SPLAM_DATA_DIR=/var/lib/splam is how the service finds the credentials you just created. systemd doesn’t read ~/.bashrc, so this is the only place that variable gets set.

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 splam

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 splam
● splam.service - splam
   Loaded: loaded (/etc/systemd/system/splam.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/splam.service
           └─4812 /opt/splam/.venv/bin/python3.12 /opt/splam/.venv/bin/shiny run splam.app:app --host 127.0.0.1 --port 8000

Sep 05 18:22:41 ip-172-31-24-9 systemd[1]: Started splam.
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 login 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 splam -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 splam -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 splam:

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/splam/.venv/bin/shiny
ModuleNotFoundError: No module named 'splam' pip install -e . ran somewhere other than /opt/splam, 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.

Putting nginx in front

The app is listening on loopback, which means nothing outside the instance can reach it. Something has to accept the connection on 443, terminate TLS, and forward the request inward.

Install nginx:

sudo amazon-linux-extras install -y nginx1

Create the site configuration:

sudo vim /etc/nginx/conf.d/splam.conf
server {
    listen 443 ssl;
    server_name splam.example.com;

    ssl_certificate     /etc/letsencrypt/live/splam.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/splam.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }
}

The certificate paths assume you’ve been through Configure SSL and have a hostname pointed at the instance from Configure DNS. An Elastic IP is worth allocating here, because without one a stopped and restarted instance comes back with a new address and the DNS record goes stale.

Test the configuration before reloading, since a syntax error will otherwise take nginx down entirely:

sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Then enable and start it, the same two step pattern as the app:

sudo systemctl enable --now nginx

The proxy_http_version, Upgrade, and Connection lines are not boilerplate. Shiny for Python holds its connection to the browser open over a WebSocket rather than plain request and response HTTP.

Leave them out and the failure is confusing rather than obvious: the page loads, the layout renders, and then nothing responds. No error appears in the browser, and journalctl -u splam shows a session opening and immediately closing. Any Shiny app behind any proxy needs this, in R or in Python.

Load the hostname in a browser and you should get the login screen, served over HTTPS, backed by the account you created earlier.

Recap

  1. splam calls systemctl and journalctl on its own host, so it runs directly on the EC2 instance rather than in a container. That single constraint is what makes this a systemd plus reverse proxy deployment instead of a Docker one.

  2. Amazon Linux 2’s Python is too old and belongs to yum. Build 3.12 into /opt/python/3.12.1 with make altinstall, put the app in /opt/splam, its state in /var/lib/splam, and leave the system interpreter alone.

  3. systemd runs no shell and reads no startup files. Absolute paths in ExecStart, Environment= lines for anything the app expects from the environment, and --host 127.0.0.1 so the only way in is through the proxy.

  4. Verify in three places before you call it deployed: systemctl status for the unit state, curl -I http://127.0.0.1:8000 for the app, and the browser over HTTPS for the proxy. When one of them fails, journalctl -u splam -n 50 names the reason.