Stream live video from a USB webcam on a Particle Tachyon to a public URL, with device status and remote commands flowing through Particle Cloud events. No port forwarding, no fixed IP, no firmware, and no code changes: the whole application is one Docker Compose project.
- Difficulty: Intermediate
- Estimated time: 30 minutes
- Hardware needed: Tachyon, USB (UVC) webcam
- Code: this repository
The application runs as four containers on the Tachyon. Video flows out through a Cloudflare tunnel; control and status flow through the Particle Cloud. Both paths use only outbound connections, so everything works the same on Wi-Fi behind a home router or on 5G behind carrier-grade NAT.
USB webcam
|
| /dev/videoN (V4L2, MJPG preferred)
v
+-- Tachyon ---------------------------------------------------------------+
| |
| [camera] [mediamtx] [app] |
| ffmpeg capture --RTSP--> HLS / WebRTC <--HLS proxy-- landing page, |
| | server ^ snapshot, /api, |
| | +----status API--- Particle agent |
| +--JPEG--> (snapshots volume) ---------read-----> | ^ |
| | | |
| [cloudflared] <------------- HTTP (app:8080) ----------+ | |
| | | |
+------|-------------------------------------------------------------|-----+
| outbound only | HTTPS
v v
https://<random>.trycloudflare.com Particle Cloud
public viewers (HLS in any browser) events: online, status,
snapshot, cmd
The demo moment: power the device, watch the Console event log, and a
tachyon-cam/online event arrives carrying a clickable public URL.
- Particle Tachyon, set up and online in the Particle Console (desktop or headless OS variant; both work identically since the app is containerized)
- A USB (UVC) webcam, for example a Logitech C920 class device
- A USB-C hub if your webcam is USB-A
No other computer is required: every step happens in a shell opened through the Particle Console, and cloud identity comes from the device itself.
- A Tachyon that has completed setup and appears online in the Particle Console
- Particle for Linux 0.20.2 or later and on-device Particle CLI 3.42.1 or
later (check with
particle versionin the device shell) - The device identity file written during setup,
~/.particle/particle.config.json(verify withls ~/.particle/particle.config.json). The app feeds these credentials to particle-api-js, Particle's supported JavaScript API client, so there are no user-created access tokens to manage.
All dependencies are pulled or built on the device by Docker Compose:
- MediaMTX: the streaming server (RTSP ingest, HLS and WebRTC output)
- ffmpeg (Alpine package, inside the camera container)
- cloudflared: the outbound tunnel
- Node.js 20 + Express (inside the app container)
- particle-api-js: Particle's supported JavaScript API client, used for event publishing and the command subscription, installed by npm during the image build
All device-side steps run in a shell opened through the Particle Console, so you never need physical access beyond plugging in the webcam.
Connect the USB webcam to the Tachyon (through a hub if needed). If the device is in an enclosure in the field, this is the only physical step.
In the Particle Console, open your device's
details page and select the Terminal tab, then open a new shell. Switch
to the user account you configured during setup (the default is
particle, with the password you chose):
su - particlels /dev/video*You should see one or more nodes. Do not assume the webcam is
/dev/video0: on Tachyon, /dev/video0 and /dev/video1 can be Qualcomm
media framework endpoints, and other nodes may belong to the hardware video
codec. The blueprint auto-detects the correct node, so you only need to
confirm that nodes exist. If nothing appears, check lsusb and try a
different port or a powered hub.
cd ~
git clone https://github.com/particle-iot/blueprint-tachyon-webcam-stream.git
cd blueprint-tachyon-webcam-streamThere is nothing you have to configure: cloud identity comes from the
device's own particle.config.json, and events are attributed to this
device automatically. To change defaults such as resolution, bitrate, or
the name shown on the page:
cp webcam-stream/.env.example webcam-stream/.env
nano webcam-stream/.envIf DEVICE_NAME is left empty, the device's name from the Particle
Console is used.
particle container runThe first run builds the two custom images and pulls the other two, which takes a few minutes. When everything is up you will see, in order:
camera: using /dev/videoN (mjpeg) at 1280x720@30fpsapploggingParticle identity: user=... device=... product=...- a
trycloudflare.comURL in the cloudflared logs apploggingpublic URL: https://<random>.trycloudflare.comandpublished tachyon-cam/online ...
Events publish into your product's event stream using the device's own provisioned credentials, and every payload carries the device's name, so the Console event log reads cleanly even with several devices running this blueprint.
Open the Console's Events view. The tachyon-cam/online event carries
the public URL in its data; open it on any browser, including your phone on
cellular, to see the live stream. On the same LAN as the Tachyon you can
also use http://<tachyon-ip>:8080 for the same page, or
http://<tachyon-ip>:8889/cam for sub-second WebRTC latency.
In the Events view, use Publish event:
- Name
tachyon-cam/cmd, datasnapshot: the device replies with atachyon-cam/snapshotevent containing a public link to the latest JPEG. - Name
tachyon-cam/cmd, datastatus: the device replies immediately with atachyon-cam/statusevent (uptime, stream health, viewer counts).
A tachyon-cam/status heartbeat also arrives automatically every 5
minutes.
Ctrl+C in the terminal stops the foreground run. To run the application
persistently and start it on boot, deploy it through the cloud instead:
particle container push --device <your-device-id>This builds the images on the device, pushes them to Particle's container
registry, and installs the application through the cloud. It starts
automatically, survives reboots, and appears in the Console under the
device's Containers tab. The device choice is remembered in
.particle_env.yaml, so later pushes are just particle container push.
Pushed applications carry only the compose file and the images: project
files do not travel (which is why the MediaMTX config is baked into its
image), and .env does not either, so defaults apply unless you hardcode
values in docker-compose.yaml before pushing. This is also the workflow
for headless production devices, and requires no changes to the project. Note that particle container run deployments are
local only and never appear in the Containers tab.
In docker ps, cloud-managed containers carry a particle_ name prefix,
which is the quickest way to tell them apart from local runs.
The local and cloud-deployed forms of this application use the same ports,
so only one can run at a time: stop any foreground particle container run
(Ctrl+C) before or right after pushing, then reboot or push again so the
cloud-managed containers can start.
The repo follows Particle's container blueprint convention: the
containers: key in blueprint.yaml points the Particle CLI at the
webcam-stream/ directory, which holds the Docker Compose project. Running
particle container run from the repo root builds and starts everything in
that directory.
camera probes /dev/video* until it finds a node that reports MJPG or
YUYV capture formats (that is the UVC webcam; Qualcomm framework and codec
nodes do not). It captures MJPG when available, encodes to H.264 with
libx264 tuned for live latency, and publishes over RTSP to MediaMTX. The
same ffmpeg process writes a JPEG to a shared volume every few seconds. The
container runs privileged with /dev mounted so detection works no matter
where the webcam enumerates; see Extensions for the tighter alternative.
mediamtx turns the RTSP feed into low-latency HLS (what public viewers receive) and WebRTC (for the LAN). Its configuration is baked into a thin custom image rather than bind mounted, because pushed applications carry only the compose file and container images; any config file a service needs must live inside an image. Its control API lets the app report whether the stream is live and how many readers are attached.
app is the single public origin behind the tunnel. It serves the
landing page, the latest snapshot, and reverse-proxies HLS requests to
MediaMTX. It is also the Particle Cloud agent, built on
particle-api-js,
Particle's supported JavaScript API client. The device's provisioned
particle.config.json is mounted read-only into the container and its
credentials are passed to publishEvent() (product-scoped) and
getEventStream(). Inbound tachyon-cam/cmd events arrive on the
client's EventStream, which parses the SSE protocol and reconnects
automatically after network drops, which matters on long-lived cellular
connections.
cloudflared opens the outbound tunnel. By default it is a quick tunnel
with a random hostname, which the app reads from cloudflared's metrics
endpoint and announces via the online event.
| Event | Direction | Data |
|---|---|---|
tachyon-cam/online |
device to cloud | {device, url} on boot, once the tunnel URL is known |
tachyon-cam/status |
device to cloud | {device, uptime_s, stream_ready, readers, page_views, url} every STATUS_INTERVAL seconds and on request |
tachyon-cam/snapshot |
device to cloud | {device, url} link to the latest JPEG |
tachyon-cam/cmd |
cloud to device | snapshot or status |
| Port | Container | Purpose |
|---|---|---|
| 8080 | app | Landing page, snapshot, HLS proxy (tunnel origin; also on LAN) |
| 8888 | mediamtx | HLS direct (LAN) |
| 8889, 8189/udp | mediamtx | WebRTC (LAN) |
| 8554 | mediamtx | RTSP ingest (compose network only) |
| 9997 | mediamtx | Control API (compose network only) |
| 2000 | cloudflared | Metrics, quick tunnel URL discovery (compose network only) |
Once deployed with particle container push, the app can be started and
stopped from any shell on the device, including the Console terminal
(which runs as root):
# stop the stream and tunnel (until the next boot; see the note below)
docker ps --format '{{.Names}}' | grep '^particle_webcam-stream' | xargs -r docker stop
# start it again (a new quick tunnel URL is minted and announced via the online event)
docker ps -a --format '{{.Names}}' | grep '^particle_webcam-stream' | xargs -r docker start
# read the current public URL and stream state
curl -s http://localhost:8080/api/infoAfter a start, the stream takes roughly 10 to 20 seconds to come online while the services reconnect to each other; the landing page retries automatically and flips to LIVE on its own.
A deployed application is restored by the device agent at every boot,
regardless of Docker restart policies or a manual docker stop, so the
app always comes back after a reboot or power loss. That is the right
behavior for a headless product. There is currently no supported way to
keep a deployed application stopped across reboots short of removing it
(particle container remove). For a demo bench that should stay down
until started manually, a small boot-time systemd unit that stops the
containers after the agent starts them works as a local workaround.
Day to day, the only interfaces you need are the public URL and the
Console. The landing page shows the live stream with device name, uptime,
and viewer count, plus a link to the latest snapshot. Tuning knobs live in
webcam-stream/.env: resolution, frame rate, bitrate, snapshot cadence, and heartbeat
interval. After changing .env, restart with particle container run.
| Symptom | Check |
|---|---|
camera: no UVC webcam found |
lsusb shows the camera? Try another port or a powered hub. Set CAM_DEVICE=/dev/videoN explicitly if your camera reports unusual formats. |
No online event |
Look for the app's startup logs. Particle device config not found means the identity mounts did not resolve: confirm ls ~/.particle/particle.config.json succeeds for the user that ran particle container run, and adjust the volume path in webcam-stream/docker-compose.yaml if you use a different account. |
Containers tab shows the app but containers stay created |
The cloud-deployed containers failed their first start, usually a port conflict with a foreground particle container run of the same project. The agent does not retry a failed start, and a reboot does not recover it (Docker restart policies only revive containers that previously ran). Free the ports, then run particle container push again to reinstall cleanly. |
Page shows OFFLINE while the logs show [path cam] stream is available |
The app could not reach the MediaMTX control API. MediaMTX's default auth allows the api action only from localhost; this blueprint's mediamtx.yml includes an authInternalUsers override for it, so make sure that block is present if you copied an older config. |
The access token provided is invalid |
The token in ~/.particle/particle.config.json is the CLI session token written at setup, and it can expire or be revoked. Run particle login on the device to refresh it in place (deviceId and productId are preserved), then rerun. |
cloud events disabled with missing fields |
The mounted particle.config.json lacks access_token, deviceId, or productId. Re-run device setup, or check that the file was not hand-edited. |
| URL event never arrives but the tunnel works | The app reads the quick tunnel hostname from cloudflared's metrics endpoint. Grab the URL from the cloudflared logs, or set PUBLIC_URL in .env. |
| Environment values not applied | If your tooling version does not interpolate .env, set the values directly in webcam-stream/docker-compose.yaml. |
| Video is choppy through the tunnel | Lower RESOLUTION to 960x540 or BITRATE to 1M. As a fallback, set hlsVariant: mpegts in webcam-stream/mediamtx/mediamtx.yml (higher latency, more tolerant). |
| WebRTC works on the device but not from other LAN machines | Add the Tachyon's LAN IP to webrtcAdditionalHosts in webcam-stream/mediamtx/mediamtx.yml. |
| Commands ignored | Event name must be exactly tachyon-cam/cmd (or your EVENT_PREFIX), data snapshot or status. |
- Containerized applications on Tachyon with Docker Compose and the Particle CLI
- V4L2 USB camera capture and live H.264 encoding with ffmpeg
- Serving live video with MediaMTX (HLS and WebRTC)
- Public exposure of a NAT'd cellular device with an outbound tunnel
- Particle Cloud events as a device control plane from Linux, using particle-api-js, Particle's supported JavaScript API client
- Stable production URL: create a named Cloudflare tunnel with your own
domain, set
TUNNEL_TOKENandPUBLIC_URL, and switch the cloudflared command as noted indocker-compose.yaml. - Tighter device access: replace
privileged: trueon the camera container with adevice_cgroup_rulesentry for char major 81 (V4L2) plus the/devbind, or map the specific/dev/videoNnode once known. - Hardware encoding: the QCM6490's Venus encoder is reachable through V4L2/GStreamer; swapping libx264 for hardware H.264 frees CPU for inference workloads.
- Alerting with Logic: trigger a notification if the
statusheartbeat stops arriving or reportsstream_ready: false. - Ledger-driven configuration: move resolution and bitrate into a cloud-to-device Ledger so the fleet can be retuned without redeploying.
- Edge AI overlay: add a detection container (see the Qualcomm AI Hub Models blueprint) and draw results onto the stream before encoding.