tapo-uploader: record your Tapo camera on motion, keep the videos in your own storage
🌐 English · Italiano
A self-hosted web app that records your TP-Link Tapo camera (tested on the Tapo C200) only when it detects motion and keeps the videos in the storage you choose: YouTube, your own OneDrive, Nextcloud or any WebDAV, any rclone cloud (Mega, S3, Dropbox...) or the disk of the machine that runs it. One page to watch everything, whichever storage it lives in. No cloud subscription, no firmware hacking.
The Tapo C200 can't send its recordings to your own storage by itself. tapo-uploader is a small
Python service that pulls the camera's RTSP stream with ffmpeg, listens for ONVIF motion
events, and saves the result where you decide. Upload destinations can be mixed and changed at any
time: the videos you saved earlier stay on your page, each marked with the storage it lives in. It
runs for one person from a single .env, or for a group of friends with accounts, each with their
own camera and storage. A Raspberry Pi, your own PC, a cheap VPS or a Docker container is enough.
Tapo C200 ──RTSP──▶ ffmpeg ──▶ 30 s before + 5 min after each motion ──▶ one video a day, with chapters
(home) ──ONVIF motion events──▶ ──▶ YouTube · OneDrive · WebDAV · rclone · disk
(a page to watch them all)
Features
- Motion-triggered recording. Keeps 30 seconds before the detection and 5 minutes after; motion near the end of the window extends it. All times are configurable.
- One video per day. Clips are joined without re-encoding and uploaded once a day (21:00 by default), with the time of every event as chapters in the description.
- Or continuous recording. 6-hour segments, each uploaded as its own video.
- Your storage, your choice. YouTube (private videos, a playlist per day), your own OneDrive, Nextcloud/WebDAV, any rclone cloud, or the server's disk; change it any time and nothing is lost.
- One page for everything. The web app lists the videos of every storage you ever used, each marked with where it lives, plays them in the page, and also shows clips still waiting for their upload.
- Frees the disk. Files are deleted right after a successful upload; videos kept on the server can rotate by age, by a space limit per person, or when the disk runs low.
- No re-encoding.
ffmpeg -c copy: close to zero CPU, so a Raspberry Pi is plenty. - Resilient. Restarts ffmpeg when the stream drops, retries failed uploads, handles the YouTube quota, survives reboots (fragmented MP4 stays playable even if killed).
- Credentials never logged. The camera password is masked in every log line.
- Runs anywhere. systemd unit,
Dockerfile, and Render blueprints included. - A complete, honest guide. Every step of a real setup is documented, including the dead ends.
Contents
- How it works
- Requirements
- 1. The camera
- 2. Google Cloud and YouTube (only if you use YouTube)
- 3. Install and test on your PC
- Where to run it (including your own PC)
- 4. The server
- 5. Reaching the camera from the internet
- 6. Final check
- Motion mode: one video per day
- Multi-account mode
- Where the recordings go
- Daily use
- Configuration
- Under the hood
- YouTube quota
- Security
- Troubleshooting
- FAQ
- License
How it works
There are two modes. Both use the same camera, the same storage settings and the same .env.
Continuous (tapo_uploader.py) |
Motion (tapo_motion.py) |
|
|---|---|---|
| Records | Everything, in 6-hour segments | Only around motion: 30 s before, 5 min after |
| Uploads | Each segment as soon as it closes | One video a day, at a fixed time |
| Disk use | A few GB at most | Almost nothing (about one minute of buffer) |
| Needs | RTSP | RTSP + ONVIF events (port 2020) |
| YouTube quota (if YouTube is the storage) | 4 uploads/day | 1 upload/day |
tapo_uploader.py run starts two threads:
- Recording. ffmpeg reads the RTSP stream with
-c copy(the video is not re-encoded) and writesYYYYMMDD_HHMMSS.mp4files intorecordings/. Cuts are aligned to the wall clock: with 6-hour segments they close at 00:00, 06:00, 12:00 and 18:00. If the stream drops, ffmpeg restarts. - Upload. Every minute it looks for finished segments, sends them to the storage you chose (on YouTube as
privatevideos) titledTapo YYYY-MM-DD HH:MM-HH:MM, adds them to the playlistTapo YYYY-MM-DD(created if missing) and deletes the file. If an upload fails the file stays and is retried.
The camera can't upload on its own, so you need an always-on computer that can reach it: a Raspberry Pi or mini PC at home, a VPS, or a container host.
Requirements
| You need | Notes |
|---|---|
| A Tapo camera with RTSP | Tested on the Tapo C200. You need the "Camera Account" (step 1). |
| An always-on computer | Linux recommended (VPS, Raspberry Pi). Python 3.10+ and ffmpeg. |
| Somewhere to keep the videos | A YouTube channel (a Google account), a OneDrive, a Nextcloud/WebDAV, an rclone cloud, or just the disk. |
| Router access (only if the server is not at home) | To open a port towards the camera. |
Disk: at 640×360 a 6-hour segment is about 0.4 GB. A few free GB are enough because files are deleted after upload. Bandwidth: about 1 MB per minute of video.
Other ONVIF cameras that expose RTSP may work, but only the Tapo C200 has been tested.
1. The camera
-
In the Tapo app open the camera, then Settings (gear) → Advanced → Camera Account. Create a username and password just for the camera. They are not your Tapo/cloud account: using those is the most common mistake and gives
401 Unauthorized. -
Find the camera's IP address in the app (Device Info) or in your router, and ideally give it a fixed IP (DHCP reservation) in the router.
-
Set the video quality to High (Settings → Video Quality). It may default to low: in our case both streams were 640×360.
-
The stream address is:
rtsp://USER:PASSWORD@CAMERA_IP:554/stream1 (high quality) rtsp://USER:PASSWORD@CAMERA_IP:554/stream2 (low quality)If the password contains special characters (
!,@,:) they must be URL-encoded. The script does it for you; by hand, a letters-and-digits password is easier.
Important limit: the camera accepts one RTSP session per stream. While the service records
stream1, a second program openingstream1getsOperation not permitted. To experiment while the service runs, usestream2(CAMERA_STREAM=stream2) or stop the service first.
2. Google Cloud and YouTube
Only if you want YouTube as the storage. For OneDrive, Nextcloud/WebDAV, rclone or the disk skip to 3 and follow the storage setup guide.
The script uploads to your channel through the official YouTube Data API v3, so you need a small Google "project" that identifies the app. It is free.
2.1 Project and API
- Go to https://console.cloud.google.com/projectcreate, name it
tapo-uploader, Create. - Open https://console.cloud.google.com/apis/library/youtube.googleapis.com and press Enable (with the new project selected at the top).
2.2 OAuth consent screen
- Open https://console.cloud.google.com/auth/overview and press Get started.
- App name
tapo-uploader, support email = yours. - Audience: External. Contact email = yours. Accept and Create.
- Under Audience → Test users → Add users add your own Google account, the one that owns the YouTube channel.
2.3 Credentials
- Clients → Create client. Type: Desktop app, name
tapo-uploader. Create. - Press Download JSON and save it as
client_secret.jsonin the project folder. It is a secret: don't commit it (.gitignoreexcludes it).
2.4 Publish the app (important)
While the app stays in Testing, the access token expires after 7 days and the script stops uploading until you log in again. To avoid it, publish the app ("In production"). For personal use Google needs no verification, but the Publish app button stays disabled until Branding has:
- a link to the app home page,
- a link to the privacy policy,
- the matching authorized domain.
Use your own pages, not this project's. Google checks that the home page and the privacy policy belong to the domain of whoever owns the app, and that they describe that app. The policy in this repository names its author as the person responsible and describes the author's own server, so it would be wrong on your app and could fail Google's review. Copy
index.htmlandprivacy.html(MIT licensed) and rewrite the privacy policy for your own use: who runs it, what is stored, and who can use it (just you, or other people on your server: then say so, and name the services involved). A link back to this project on your home page is appreciated but not required. Each person who runs the software needs their own Google app and their own pages.
You don't need a real website, only two static pages. The quickest way is GitHub Pages:
- Create a public repository (this one works) with two files:
index.html(one line about what the app does) andprivacy.html(what it accesses, what is stored, how to revoke access). This repository has both, ready to copy and adapt. - In Settings → Pages choose the
mainbranch, folder/. - After a minute the pages are at
https://YOURUSER.github.io/REPO-NAME/. - In Google Cloud, Branding → App domain: enter the home page and privacy policy; under
Authorized domains → Add domain put
YOURUSER.github.io. Save. - Audience → Publish app → Confirm ("Push to production?").
Publish before creating the token (step 3). A token created while the app was in Testing can stay subject to the 7-day expiry even after publishing: to be safe, delete
token.jsonand runauthagain after publishing.
3. Install and test on your PC
These steps work on any system (Windows, macOS, Linux). You need Python 3.10+ and ffmpeg in the
PATH (ffmpeg -version to check).
git clone https://github.com/LuigiElleBalotta/tapo-uploader.git
cd tapo-uploader
python -m venv .venv
. .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # then fill in CAMERA_USER, CAMERA_PASS, CAMERA_HOST
Choose where the videos go first (see Where the recordings go; to try it with no account at all use
STORAGE=local and LOCAL_ROOT=/some/folder in .env). If you use YouTube, put client_secret.json (step 2.3) in the
folder, then:
python tapo_uploader.py check # 1. does the camera answer? prints codec and resolution
python tapo_uploader.py auth # 2. Google login, once: creates token.json
python tapo_uploader.py test # 3. records 60 s and uploads them: checks everything
checkmust printcodec_name=h264, the resolution and the audio.401 Unauthorizedmeans wrong credentials;Operation not permittedmeans the stream is already in use (see above).authprints an address: open it in a browser on the same computer, choose the Google account of the channel and allow YouTube access. The browser shows "The authentication flow has completed" andtoken.jsonappears. If, while in Testing, you see "Google hasn't verified this app": Advanced → Go to tapo-uploader.testrecords 60 seconds, uploads them as a private video and creates the day's playlist. Check that the video is on YouTube, then delete it from Studio.
If the server has no browser, run auth on your PC and copy token.json to the server.
Where to run it
The program must stay on 24/7 and be able to reach the camera. The options, simplest first:
| Where | Rough cost | How it reaches the camera | Notes |
|---|---|---|---|
| Your own PC (Windows, macOS, Linux) | free | LAN | Fine if it stays on and awake. No port forwarding. See below. |
| Raspberry Pi / mini PC at home | one-off, about $15–50 | LAN | Recommended: always on, no port forwarding, no DDNS, no exposed camera. |
| VPS (as in this guide) | a few dollars a month | port forwarding limited to the VPS's fixed IP | Steps 4 and 5. This is the tested setup. |
| Render (Background Worker) | about $7/month, plus $0.25/GB of disk | port forwarding to Render's shared IPs | Docker image included, not tested. See below. |
| Render free (web service) | $0 | port forwarding to Render's shared IPs | Tested up to startup, not with the camera. Ephemeral disk: clips can be lost. See below. |
| Vercel | – | – | Not suitable. See below. |
Other container platforms (Fly.io, Railway) follow the same pattern as Render: the Dockerfile
works anywhere, only prices and networking change.
Your own PC (Windows, macOS, Linux)
Yes, a personal computer works, with one condition: it must stay on, awake and on the same network as the camera, because it records only while the program runs. A laptop that sleeps in the evening will miss the motion. Nothing is opened on the router. A short version (the full, illustrated-by-commands guide is the PC install guide):
- Create the Camera Account in the Tapo app (step 1) and note the camera's IP address (ask the router to always give it the same one).
- Install the tools. Windows (PowerShell):
winget install Python.Python.3.12,winget install Gyan.FFmpeg,winget install Git.Git. macOS:brew install python ffmpeg git. Linux:sudo apt install python3 python3-venv ffmpeg git. git clone https://github.com/LuigiElleBalotta/tapo-uploader.git,cd tapo-uploader,python -m venv .venv, activate it (.venv\Scripts\Activate.ps1on Windows,. .venv/bin/activateelsewhere) andpip install -r requirements.txt.- Copy
.env.exampleto.envand setCAMERA_HOST,CAMERA_USER,CAMERA_PASS. To keep the videos on the PC:STORAGE=localandLOCAL_ROOT=C:\Tapo(or/home/you/Tapo). Other storages: see Where the recordings go. python tapo_uploader.py check, thenpython tapo_motion.py run.- Keep it running: turn off sleep (and "close the lid" actions on a laptop), and start it at login (Windows Task Scheduler, a macOS login item, or a systemd service on Linux).
With Docker Desktop you can skip Python and ffmpeg and use the published image.
Render (paid Background Worker)
Render can run an always-on process (a Background Worker) from a Docker image. The repository
contains Dockerfile and render.yaml. Things to know:
- Cost. The smallest worker (
0.5c-512mb: 0.5 CPU and 512 MB) is listed at $7/month. Render's free plan is for web services only, not workers. With ffmpeg in-c copymode CPU is not the limit; RAM was not measured on Render. - Disk. The container's disk is lost on every restart and deploy. Without a persistent disk
($0.25/GB per month, 1 GB is enough; it attaches to a worker) the token, the state and the day's
clips are lost.
render.yamlasks for one mounted at/data, and the script uses it throughDATA_DIR. - Secrets. A container has no files to copy:
token.jsonandclient_secret.jsonare passed as the environment variablesYOUTUBE_TOKEN_JSONandYOUTUBE_CLIENT_SECRET_JSON(the files' contents). On first start the script writes them to/datawith mode600; afterwards the refreshed token stays on disk. The login (auth) is done once on your PC. - Networking is the weak point. Render's outbound IPs are shared by every service in the same region, and a service may use any address in its range (see Render's docs). To restrict access to the camera you would open port forwarding to all of the region's ranges, so to other Render customers too. Dedicated IPs exist but, from the information found, are paid and only on Pro/Scale plans (about $100/month per set of IPs): not worth it for this. If you pick Render, the exposed port must be protected by a long, unique password and a non-standard external port; or connect home and Render with a VPN, which was not verified.
Steps: connect the repository to Render (New → Blueprint), fill in the variables it asks for
(CAMERA_USER, CAMERA_PASS, CAMERA_HOST/CAMERA_PORT, ONVIF_HOST/ONVIF_PORT,
YOUTUBE_TOKEN_JSON, YOUTUBE_CLIENT_SECRET_JSON), open the router ports to Render's IPs and watch
the worker's logs. Render only asks for the variables when the Blueprint is created: later you
change them in the dashboard. For continuous recording instead of motion mode, uncomment
dockerCommand in render.yaml.
Render free (no VPS)
For people without a server. The VPS remains the main choice: this is a zero-cost alternative
with precise limits. Deploying as a web service on the free plan was tried on Render up to
startup (build, health endpoint on port 10000, ffmpeg start); recording from the camera
through Render was not verified, because it needs the router rules described below.
How it works: the program opens a small HTTP port (/health, active when Render sets PORT) and
a free monitoring service (UptimeRobot, cron-job.org) calls it every 5 minutes. Per
Render's documentation a free service sleeps after 15 minutes
without inbound traffic; outbound traffic doesn't count, so a call from outside is needed. The
750 free monthly hours are enough for one always-on service.
Steps (the ones actually done, from the dashboard):
- On dashboard.render.com choose New → Web Service, the
Public Git Repository tab, paste the repository URL and press Connect. Render detects the
Dockerfileby itself. - Under Compute pick the $0 / month (0.1 CPU, 512 MB) row. The preselected plan is the $7/month one: if you leave it, you pay.
- Under Environment Variables press Add from .env and paste the lines below with your
values. The token and client secret must be on a single line, as compact JSON
(
python -c "import json;print(json.dumps(json.load(open('token.json')),separators=(',',':')))").CAMERA_USER=... CAMERA_PASS=... CAMERA_HOST=your-ddns-name.example.com CAMERA_PORT=external RTSP port ONVIF_HOST=your-ddns-name.example.com ONVIF_PORT=external ONVIF port CAMERA_STREAM=stream1 DATA_DIR=/data TZ=Europe/Rome YOUTUBE_TOKEN_JSON=... YOUTUBE_CLIENT_SECRET_JSON=... - Deploy web service. The build takes about a minute and a half. In the logs you should see
health endpoint on port 10000andffmpeg start. The warningfile_cache is only supported with oauth2client<4.0.0is harmless. - From the service page take the URL (
https://NAME.onrender.com) and, with the Connect → Outbound button, the IP ranges to give to the router (in our case `74
