Project studio: the system comes together
420-302-VA · WEEK 12 · FALL 2026

Stage 5 of 6 · Theory into practice · programs that stay up

From script to service

Right now your bridge runs because a terminal holds it, and dies when the terminal does: SSH drops, laptop sleeps, Pi reboots, demo over. Mosquitto has none of these problems, and since Week 9 you have known its secret's name without using it: systemd runs it as a service. Today your own program gets the same citizenship, about ten lines of configuration, and your project stops needing a babysitter.

Why your demo dies at night

A program you start from a shell is that shell's child. Close the SSH session and the system hangs up on the whole family (literally: the signal is called SIGHUP, hang up, inherited from the telephone era), and your bridge goes with it. Even surviving that, a crash at 2 am stays crashed, and a power cut on demo morning boots a Pi that runs everything except your project. A service inverts all three defaults: the init system, systemd on Raspberry Pi OS, owns the process instead of a terminal, restarts it if it dies, and starts it at boot before anyone logs in.

Two timelines across an evening, a night and a demo morning. Top, script in a terminal: a green bar labelled running ends abruptly at SSH closes, stays flat and red through a crash marker and a reboot marker, and the morning region is labelled dead since 6 pm. Bottom, systemd service: the green bar continues through SSH closes, dips briefly at crash with the label Restart equals on-failure, 2 seconds, dips at reboot with the label enabled, starts at boot, and reaches the demo region still green. 6 pm · SSH closes2 am · crash7 am · power blip9 am · demo Script in a terminal dead since 6 pm: SIGHUP took it, nothing brought it back systemd service Restart=on-failure, back in 2 s enabled: starts at boot, no login green at 9 am Same program, same bugs even. The difference is who owns the process: a terminal that leaves, or the system that stays.
The night your project survives alone. Mosquitto has lived on the lower timeline since Week 9's systemctl enable; today your bridge moves down to join it.

The unit file, line by line

A service is declared by a unit file, a dozen lines of INI-style text in /etc/systemd/system/. Here is a complete one for the Week 11 bridge; swap the paths and the name for your project's program:

The bench-bridge dot service unit file shown as code with four annotations. The Unit section's After and Wants lines point to the note: order me after the network and the broker. The Service section's User, WorkingDirectory and ExecStart lines point to: run as you, from your repo, with the venv's python, absolute paths only. Restart equals on-failure with RestartSec 2 points to: if I crash, bring me back. The Install section's WantedBy multi-user target points to: start me at every normal boot. [Unit] Description=Project MQTT bridge After=network-online.target mosquitto.service Wants=mosquitto.service [Service] User=username WorkingDirectory=/home/username/project ExecStart=/home/username/project/venv/bin/python app.py Restart=on-failure RestartSec=2 [Install] WantedBy=multi-user.target order me after the networkand the broker I need run as you, from the repo,the venv's python, absolutepaths only, no ~ here crashed? back in 2 s start at every normal boot
bench-bridge.service, annotated. Three sections: what I am and what I come after, how to run me, when to start me. The venv path in ExecStart matters: a service has no shell, no source activate, so the interpreter is named directly, Week 4's venv paying one more dividend.

The two walls, pre-announced

Both classic failures are path failures. status=203/EXEC means ExecStart does not point at a real executable, usually a ~ that systemd refuses to expand or a venv path typed from memory; ModuleNotFoundError in the log means the system python ran instead of the venv's. Both are diagnosed the same way: journalctl -u bench-bridge -n 30 and read, the Week 9 reflex.

Install, enable, watch

  1. Place the unit. sudo nano /etc/systemd/system/bench-bridge.service, paste, save; then sudo systemctl daemon-reload so systemd rereads its catalogue.
  2. Start it once, by hand. sudo systemctl start bench-bridge, then the Week 9 pair of eyes: systemctl status bench-bridge for the headline, journalctl -u bench-bridge -f for the live log, your print lines included.
  3. Prove the ownership transfer. Close the SSH session entirely, reopen it, curl http://localhost:5000/api/light. Fresh JSON from a program nobody is holding: that is the point, observed.
  4. Enable, then the real test. sudo systemctl enable bench-bridge, sudo reboot, wait, and load the dashboard from your laptop without SSHing in at all. A Pi that serves your project from cold power is a Pi you can carry to a demo.

Keep a copy of the unit file in the repo under deploy/, with the install commands in a comment. The file in /etc/systemd/system/ is configuration, and Week 3's lesson about configuration has not aged: if it is not in the repo, it does not survive a re-image.

Honest limits

Week 11's honesty note still binds: the Flask dev server inside your service is fine for a bench and a classroom demo, not for the open internet, and a service restarting your program does not fix the reasons it crashes, it only buys you the log that says why (journalctl keeps the stack trace of every death). Note also what the ladder's higher rungs would be, since the colleague-run editions of this course deployed whole projects this way: one unit per program, node excepted (the ESP32 needs no systemd; main.py at boot has been its service since Week 8), a status topic so services report into MQTT itself, and log rotation for the long-running. For D5, the single unit you wrote today is the difference between a demo and a prayer.

Checklist for this stage

Check yourself

Why does ExecStart name the venv's python explicitly instead of relying on an activated environment?
Activation is a shell convenience: source venv/bin/activate edits the current shell's PATH so that python resolves to the venv's interpreter. A systemd service has no login shell and no activation step, so a bare python would resolve to the system interpreter, which, per Week 4's PEP 668 lesson, does not have your packages, and the service dies at its first import. Naming /home/username/project/venv/bin/python skips the convenience and uses the mechanism directly: that file is the venv's interpreter, packages and all.
Restart=on-failure brings your bridge back in 2 seconds, forever. Construct the case where this makes a bug harder to notice, and the habit that defends against it.
A bridge that crashes on, say, every malformed payload now dies and returns every few minutes; the dashboard blinks stale for two seconds and recovers, so the symptom is too small to catch by eye, and the system looks healthy while crashing hundreds of times a day. The defence is the log, not the uptime: journalctl -u bench-bridge shows every death with its stack trace, and a periodic glance (or noticing the same trace repeated) turns the invisible crash-loop into a diagnosis. Restart buys availability; only reading buys correctness.
Your service shows active (running) but curl gets connection refused on port 5000. Which segment does that pair of facts acquit, and where do you probe next?
Active (running) acquits process existence: systemd launched something and it has not exited. Connection refused on localhost says nothing is listening on 5000, so the running process is not serving, wrong program variant, Flask crashed past its import but the process lingers, or the app bound elsewhere. Probe the service's own output next: journalctl -u bench-bridge -n 30. You are looking for Flask's startup banner with its address line; a missing banner or a traceback locates the fault inside the program, while a banner showing 127.0.0.1 versus 0.0.0.0 replays Week 11's bind-address wall, now one layer deeper.