Install splam

Published

2026-09-23

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

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, passed to ./configure above, is what triples the compile time. It builds Python twice: once to produce an instrumented interpreter, then runs it against Python’s own test suite to record which code paths get hit most, then rebuilds using that profile data to optimize those paths (profile guided optimization, or PGO). It also turns on link time optimization (LTO). On a t3.micro the whole thing takes 20 to 30 minutes with the CPU pegged.

‘Runtime speed’ here means how fast the finished interpreter executes Python code after it’s installed, not how long the build itself takes. PGO typically buys 10 to 20 percent on CPU bound benchmarks. splam isn’t CPU bound: it spends its time waiting on subprocess calls (systemctl, journalctl) and network I/O, so that speedup is not something you’d notice in practice. Drop the flag (sudo ./configure --prefix=/opt/python/3.12.1) and the build finishes in a few minutes with a standard, unoptimized interpreter that runs splam exactly as well.

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.

More on the flag itself: https://docs.python.org/3/using/configure.html#cmdoption-enable-optimizations

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 .

When it’s complete, you should see something like the following:

Successfully built splam
Installing collected packages: websockets, wcwidth, typing-extensions, setuptools, python-multipart, platformdirs, packaging, orjson, narwhals, mdurl, linkify-it-py, libsass, idna, h11, click, asgiref, annotated-types, uvicorn, typing-inspection, pydantic-core, prompt-toolkit, opentelemetry-api, markdown-it-py, htmltools, anyio, watchfiles, starlette, questionary, pydantic, mdit-py-plugins, shinychat, shiny, splam
Successfully installed annotated-types-0.8.0 anyio-4.15.1 asgiref-3.12.1 click-8.5.0 h11-0.16.0 htmltools-0.7.0 idna-3.19 libsass-0.23.0 linkify-it-py-2.2.0 markdown-it-py-4.2.0 mdit-py-plugins-0.6.1 mdurl-0.1.2 narwhals-2.26.0 opentelemetry-api-1.44.0 orjson-3.12.0 packaging-26.3 platformdirs-4.11.9 prompt-toolkit-3.0.53 pydantic-2.13.5 pydantic-core-2.46.5 python-multipart-0.0.32 questionary-2.1.1 setuptools-84.0.0 shiny-1.8.0 shinychat-0.7.1 splam-0.1.0 starlette-1.6.0 typing-extensions-4.16.0 typing-inspection-0.4.4 uvicorn-0.53.0 watchfiles-1.2.0 wcwidth-0.8.3 websockets-17.1

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.

The interpreter you just built has no SSL support, so pip can’t reach PyPI over HTTPS at all. The cause traces back to the ./configure step: Amazon Linux 2’s default openssl-devel package is OpenSSL 1.0.2k, and Python 3.10 and newer require 1.1.1 or later to build the _ssl extension. Configure looked for a new enough OpenSSL, didn’t find one, and silently skipped the module rather than failing the build. make altinstall finished without complaint because none of the steps you ran so far touch SSL, so the problem doesn’t show up until pip tries to talk to the internet.

AL2 ships that newer OpenSSL under a separate package, openssl11, but its -devel package can’t be installed alongside the old one: both own the same header path, /usr/include/openssl/*.h, so yum refuses with a conflict rather than letting them collide. The fix is to swap the devel package out, build, then swap it back, since Python only needs the headers at build time, not at runtime:

sudo yum remove -y openssl-devel

sudo yum install -y openssl11-devel

cd /tmp/Python-3.12.1

sudo ./configure --prefix=/opt/python/3.12.1 --enable-optimizations

sudo make altinstall

Python’s configure script checks for openssl11 via pkg-config before falling back to the system default, which is what makes this work without a --with-openssl flag. You don’t need to redownload the source or delete the existing /opt/python/3.12.1 tree, altinstall overwrites in place.

Confirm the module actually built this time before retrying the venv:

/opt/python/3.12.1/bin/python3.12 -c 'import ssl; print(ssl.OPENSSL_VERSION)'

That should print an OpenSSL 1.1.1 version string, not a traceback. Once it does, put the original devel package back so other builds on the box aren’t left pointing at openssl11:

sudo yum remove -y openssl11-devel

sudo yum install -y openssl-devel

Then delete the broken venv and redo the two commands from earlier in this section:

sudo rm -rf /opt/splam/.venv

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

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

When it finishes, shiny plus three splam-* entry point scripts exist inside the venv:

ls /opt/splam/.venv/bin | grep -E 'shiny|splam'
shiny
splam-chat
splam-incidents
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,font-family:monospace
    style Venv fill:#e8a33d,stroke:#000,stroke-width:1px,color:#000,font-family:monospace
    style Var fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff,font-family:monospace
    style Creds fill:#5b8c5a,stroke:#fff,stroke-width:1px,color:#fff,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
    

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:
Confirm password:
User 'admin' saved with role 'admin'

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

Set the SPLAM_DATA_DIR as /var/lib/splam:

sudo SPLAM_DATA_DIR=/var/lib/splam

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.

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:

/opt/splam/.venv/bin/splam-users add qa-reviewer --role auditor

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.

ImportantRoot is a starting point, not a destination

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, scoped to exactly the systemctl commands operators need and nothing else. The next section walks through setting one up.

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

Creating a dedicated service account

A system account exists to run one thing and nothing else, so give it exactly the shape that implies: no login shell, no password, no reason for anyone to ever land a session on it.

sudo useradd -r -s /sbin/nologin splam

-r puts it in the system UID range rather than the normal human range, so it never collides with, or gets mistaken for, somebody’s actual login. -s /sbin/nologin means any attempt to log in as splam, over SSH or locally, is rejected outright:

sudo su - splam
This account is currently not available.

That’s the message you want to see. If su - into it ever succeeds, the account is doing more than its job requires.

Hand it ownership of the state it writes to, but not the code it runs. /var/lib/splam needs to be writable by the service; /opt/splam doesn’t, and leaving that owned by root means a compromised app process can’t rewrite its own source or replace the venv it’s running from:

sudo chown -R splam:splam /var/lib/splam

Then scope exactly what it’s allowed to escalate to. Every systemctl verb and unit an operator needs gets its own line, nothing broader:

sudo visudo -f /etc/sudoers.d/splam
splam ALL=(root) NOPASSWD: /bin/systemctl restart cron
splam ALL=(root) NOPASSWD: /bin/systemctl status cron
splam ALL=(root) NOPASSWD: /bin/systemctl restart nginx
splam ALL=(root) NOPASSWD: /bin/systemctl status nginx

visudo checks syntax before it lets the file save, so a typo fails loudly instead of locking sudo for everyone. Check it explicitly anyway:

sudo visudo -c -f /etc/sudoers.d/splam
/etc/sudoers.d/splam: parsed OK

Confirm the account can actually use what you just granted, without a password prompt in the way:

sudo -u splam sudo -n systemctl status cron

Status output back means the rule is wired correctly. Anything not on the list, sudo -u splam sudo -n systemctl restart sshd for instance, should be refused rather than prompt for a password it will never get.

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

graph TD
    Splam(["splam account<br/><em>no shell, no password</em>"])
    Sudo{"<strong>sudo -n</strong><br/>/etc/sudoers.d/splam"}
    Allowed("<strong>restart / status</strong><br/>cron, nginx")
    Denied(["everything else<br/><em>denied</em>"])

    Splam --> Sudo
    Sudo --"unit is listed"--> Allowed
    Sudo --"unit is not listed"--> Denied

    style Splam fill:#d2562b,stroke:#fff,stroke-width:2px,color:#fff
    style Sudo fill:#e8a33d,stroke:#000,stroke-width:2px,color:#000
    style Allowed fill:#5b8c5a,stroke:#fff,stroke-width:2px,color:#fff
    style Denied fill:#666,stroke:#999,stroke-width:1px,color:#fff

What the Service Account Can Escalate To

A few things worth holding to for any service account, not just this one:

  • No login shell and no password. The account exists to run a process, not to be logged into, so /sbin/nologin and a locked password are the default, not an exception you add later.
  • System UID range (-r). Keeps it out of the human login range and out of any audit that lists who can access the box.
  • Own the state, not the code. The account should be able to write to its own data directory and have no write access to the application files it’s running.
  • Sudoers by allowlist, never a bare ALL grant. One line per verb and unit. A new unit an operator needs is a new line, not a broader rule.

Decide which side of it you want to live on before you hand out logins, not after. Retrofitting a scoped account onto a team that’s already used to root access is a far harder conversation than starting there.

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=splam
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

Five lines in there are doing the work worth understanding.

User=splam runs the process as the scoped account from the previous section, not root. Anything the app needs to do beyond serving requests, restarting the units listed in /etc/sudoers.d/splam, comes from that sudoers rule, not from broader privilege on the process itself.

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.