Upload ESP32-CAM Photos to a Web Server with HTTP POST and Flask
Storing photos on the ESP32-CAM's microSD card is simple, but someone has to remove the card to see them. Sending each photo to a server over Wi-Fi is far more useful: images are available immediately, they can be processed by Python or shown on a dashboard, and a stolen camera does not take the evidence with it.
In this tutorial the ESP32-CAM takes a photo every 30 seconds and uploads it with an HTTP POST request. On the other side, a short Python Flask application checks that the data is a real JPEG, verifies a shared key and saves the file with a timestamped name. You can run the server on a laptop, a Raspberry Pi or a cloud machine.
Two Ways to POST an Image
| Method | Request body | Best for |
|---|---|---|
| Raw binary | Only the JPEG bytes, Content-Type: image/jpeg | Your own server; simplest and smallest code on the ESP32 |
| multipart/form-data | JPEG wrapped in a form part with boundary lines | Servers that expect browser-style file uploads, such as PHP move_uploaded_file or third-party APIs |
Raw binary is used here because the ESP32 HTTPClient can send a buffer directly with http.POST(fb->buf, fb->len), without copying the image or building boundaries. Extra information such as the camera name and a key travels in HTTP headers.
The Flask Receiver
Install Flask with pip install flask and run the script below. It listens on port 5000 on all network interfaces so the ESP32-CAM can reach it. Note the computer's local IP address (for example with ipconfig on Windows) for use in the Arduino sketch, and allow port 5000 through the firewall.
import os
from datetime import datetime
from flask import Flask, request
app = Flask(__name__)
API_KEY = "change-this-key" # must match the ESP32 sketch
SAVE_DIR = "uploads"
MAX_BYTES = 2 * 1024 * 1024 # reject anything larger than 2 MB
@app.route("/upload", methods=["POST"])
def upload():
if request.headers.get("X-Api-Key") != API_KEY:
return "forbidden", 403
data = request.get_data()
if not data or len(data) > MAX_BYTES:
return "bad size", 400
# Every JPEG starts with FF D8 and ends with FF D9
if data[:2] != b"\xff\xd8" or data[-2:] != b"\xff\xd9":
return "not a jpeg", 400
cam = request.headers.get("X-Camera-Id", "unknown")
cam = "".join(ch for ch in cam if ch.isalnum() or ch in "-_") # safe name
folder = os.path.join(SAVE_DIR, cam)
os.makedirs(folder, exist_ok=True)
name = datetime.now().strftime("%Y%m%d_%H%M%S.jpg")
with open(os.path.join(folder, name), "wb") as f:
f.write(data)
print(f"saved {cam}/{name} ({len(data)} bytes)")
return "ok", 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
The ESP32-CAM Sketch
#include <WiFi.h>
#include <HTTPClient.h>
#include "esp_camera.h"
const char *ssid = "YourWiFiName";
const char *password = "YourWiFiPassword";
const char *serverUrl = "http://192.168.1.10:5000/upload"; // PC running Flask
const char *apiKey = "change-this-key";
const char *cameraId = "gate-cam";
const unsigned long INTERVAL_MS = 30000;
// AI-Thinker ESP32-CAM pin map
#define PWDN_GPIO_NUM 32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 0
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 21
#define Y4_GPIO_NUM 19
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22
bool initCamera() {
camera_config_t c = {};
c.ledc_channel = LEDC_CHANNEL_0;
c.ledc_timer = LEDC_TIMER_0;
c.pin_d0 = Y2_GPIO_NUM; c.pin_d1 = Y3_GPIO_NUM;
c.pin_d2 = Y4_GPIO_NUM; c.pin_d3 = Y5_GPIO_NUM;
c.pin_d4 = Y6_GPIO_NUM; c.pin_d5 = Y7_GPIO_NUM;
c.pin_d6 = Y8_GPIO_NUM; c.pin_d7 = Y9_GPIO_NUM;
c.pin_xclk = XCLK_GPIO_NUM; c.pin_pclk = PCLK_GPIO_NUM;
c.pin_vsync = VSYNC_GPIO_NUM; c.pin_href = HREF_GPIO_NUM;
c.pin_sccb_sda = SIOD_GPIO_NUM; c.pin_sccb_scl = SIOC_GPIO_NUM;
c.pin_pwdn = PWDN_GPIO_NUM; c.pin_reset = RESET_GPIO_NUM;
c.xclk_freq_hz = 20000000;
c.pixel_format = PIXFORMAT_JPEG;
c.frame_size = FRAMESIZE_SVGA;
c.jpeg_quality = 12;
c.fb_count = 1;
c.fb_location = CAMERA_FB_IN_PSRAM;
c.grab_mode = CAMERA_GRAB_LATEST;
return esp_camera_init(&c) == ESP_OK;
}
void connectWiFi() {
if (WiFi.status() == WL_CONNECTED) return;
WiFi.begin(ssid, password);
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) {
delay(500);
Serial.print(".");
}
Serial.println(WiFi.status() == WL_CONNECTED ? " connected" : " failed");
}
void uploadPhoto() {
camera_fb_t *fb = esp_camera_fb_get();
if (fb) esp_camera_fb_return(fb); // discard stale frame
fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Capture failed");
return;
}
HTTPClient http;
http.begin(serverUrl);
http.setTimeout(15000); // ms, allow for slow links
http.addHeader("Content-Type", "image/jpeg");
http.addHeader("X-Api-Key", apiKey);
http.addHeader("X-Camera-Id", cameraId);
unsigned long t0 = millis();
int code = http.POST(fb->buf, fb->len); // send the frame buffer directly
unsigned long dt = millis() - t0;
esp_camera_fb_return(fb); // buffer no longer needed
if (code > 0) {
Serial.printf("HTTP %d in %lu ms: %s\n", code, dt, http.getString().c_str());
} else {
Serial.printf("POST failed: %s\n", http.errorToString(code).c_str());
}
http.end();
}
void setup() {
Serial.begin(115200);
if (!initCamera()) {
Serial.println("Camera init failed");
while (true) delay(1000);
}
connectWiFi();
}
void loop() {
static unsigned long last = 0;
if (millis() - last >= INTERVAL_MS || last == 0) {
last = millis();
connectWiFi(); // reconnect if the router dropped us
if (WiFi.status() == WL_CONNECTED) uploadPhoto();
}
}
How It Works
http.POST(fb->buf, fb->len)streams the JPEG straight from the frame buffer, so no second copy of the image is needed in RAM.- The buffer is returned to the camera driver right after the POST, before the response is printed, so the camera is never blocked for long.
- The server checks the JPEG start (FF D8) and end (FF D9) markers. A truncated upload, which happens when Wi-Fi drops mid-transfer, fails this check and is rejected instead of being saved as a broken file.
- The camera ID header is cleaned to letters, digits, dash and underscore before being used as a folder name, which prevents path tricks such as
../. connectWiFi()is called before every upload with a 15 second limit, so the camera recovers by itself after a router restart.
When the Server Expects a Form Upload
Many shared-hosting PHP scripts and online services only accept files sent the way a browser sends them, as multipart/form-data. In that case the body must wrap the JPEG between boundary lines, and the Content-Type header must name the same boundary. The structure looks like this:
POST /upload.php HTTP/1.1 Host: example.com Content-Type: multipart/form-data; boundary=CamBoundary Content-Length: <exact total of everything below> --CamBoundary Content-Disposition: form-data; name="imageFile"; filename="cam.jpg" Content-Type: image/jpeg <JPEG bytes> --CamBoundary--
On the ESP32 you build the text before and after the image as two strings, add their lengths to fb->len for Content-Length, then write the head, the JPEG and the tail to a WiFiClient in order. The field name, here imageFile, must match what the server reads, for example $_FILES["imageFile"] in PHP. The Telegram photo bot tutorial uses exactly this technique over HTTPS.
Ideas for Extending the Server
- Add a route that lists the newest files as an HTML gallery, so you can check the camera from a phone browser.
- Delete files older than a set number of days with a scheduled task, so the disk does not fill up.
- Run OpenCV on each uploaded image to compare it with the previous one and keep only images where something changed.
- Return a short command in the response body, such as a new interval, and let the ESP32 parse it with
http.getString().
Troubleshooting
- POST failed: connection refused: Flask is not running, is bound to 127.0.0.1 instead of 0.0.0.0, or the firewall blocks port 5000.
- HTTP 403: the key in the sketch and the server do not match exactly.
- HTTP 400 not a jpeg: the upload was cut off; check Wi-Fi signal and increase the timeout.
- Uploads take several seconds: reduce the frame size or raise the JPEG quality number to make smaller files.
- Server works in a browser but not from the ESP32: the PC and the ESP32 are on different networks, such as a guest Wi-Fi with client isolation.
Summary
With HTTPClient the ESP32-CAM can upload a frame buffer as a raw image/jpeg POST in a few lines, adding identification and a key in headers. A small Flask app validates the size, key and JPEG markers before saving, which keeps broken files and strangers out. From here you can add image processing on the server, a gallery page, or HTTPS for uploads over the internet.