%%{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
Install splam
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:
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.
Getting a modern Python onto the instance
splam needs Python 3.11 or newer. 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 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
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 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.1Installing 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/splamCreate the virtual environment from the /opt Python, not the system one:
sudo /opt/python/3.12.1/bin/python3.12 -m venv /opt/splam/.venvThen 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-usersWhere 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.
| 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
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 adminPassword:
Repeat for confirmation:
Created user 'admin' (role: admin) in /var/lib/splam/credentials.jsonThe 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 listadmin adminThere’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 auditorIf 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.
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.serviceAnd 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.targetFour 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 splamdaemon-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: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 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.
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 nginx1Create the site configuration:
sudo vim /etc/nginx/conf.d/splam.confserver {
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 -tnginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successfulThen enable and start it, the same two step pattern as the app:
sudo systemctl enable --now nginxLoad the hostname in a browser and you should get the login screen, served over HTTPS, backed by the account you created earlier.
Recap
splamcallssystemctlandjournalctlon its own host, so it runs directly on the EC2 instance rather than in a container. That single constraint is what makes this asystemdplus reverse proxy deployment instead of a Docker one.Amazon Linux 2’s Python is too old and belongs to
yum. Build 3.12 into/opt/python/3.12.1withmake altinstall, put the app in/opt/splam, its state in/var/lib/splam, and leave the system interpreter alone.systemdruns no shell and reads no startup files. Absolute paths inExecStart,Environment=lines for anything the app expects from the environment, and--host 127.0.0.1so the only way in is through the proxy.Verify in three places before you call it deployed:
systemctl statusfor the unit state,curl -I http://127.0.0.1:8000for the app, and the browser over HTTPS for the proxy. When one of them fails,journalctl -u splam -n 50names the reason.