A Django web application for event guests to upload and download photos and videos in real time. Built to run on a humble home server with AWS handling all the heavy lifting — file storage (S3), thumbnail generation (Lambda), and ZIP downloads (Lambda).
- Overview
- Architecture
- Project Structure
- Requirements
- Local Development Setup
- Docker Setup
- AWS Setup
- Raspberry Pi Time Sync
- Environment Variables Reference
- Authentication
- Nginx
Guests access the app via a magic token link (no password needed), upload photos and videos from their phones, and browse a gallery. They can select files and download them as a ZIP.
Everything heavy is offloaded to AWS so the Raspberry Pi only handles lightweight API calls:
- Images are compressed and converted to WebP in the browser before upload
- Files go directly from the browser to S3 via presigned POST URLs — they never pass through the server
- Thumbnails are generated automatically by a Lambda triggered by S3
- ZIP downloads are created by a second Lambda and delivered straight from S3 to the user
Browser
│
├── GET /gallery/ ──► Django (Pi) ──► DB (file records)
│
├── POST /get_upload_url/ ──► Django (Pi) ──► returns S3 presigned POST URL
│
├── POST (file) to S3 ─────────────────────────────► S3 (uploads/)
│ │
│ S3 triggers Lambda
│ │
│ Lambda: generate-thumbnail
│ │
│ └──► S3 (thumbnails/)
│
├── POST /save_file_url/ ──► Django (Pi) ──► saves S3 key + thumbnail key to DB
│
└── POST /download_selected_zip/
──► Django (Pi) ──► Lambda: generate-gallery-zip
│
├──► pulls files from S3
├──► saves ZIP to S3 (zips/)
└──► returns presigned URL ──► browser downloads from S3
Key design decisions:
- The thumbnail key is derived deterministically from the upload key:
uploads/xxx_file.mp4→thumbnails/xxx_file.webp. Django saves this path immediately when the file record is created, before Lambda has finished generating it. A fallback placeholder image is shown if the thumbnail is not yet ready. - Lambda never calls back Django. The thumbnail path is predictable so no coordination is needed.
- Files are private in S3. All URLs served to the browser are presigned and expire after 1 hour.
weddingram/
├── auth/
│ └── backends.py # Token-based authentication backend
├── photos/
│ ├── assets/ # Source JS, CSS, images (pre-collectstatic)
│ │ ├── css/styles.css
│ │ ├── img/
│ │ │ ├── logo.png
│ │ │ ├── img_thumbnail.jpg # Placeholder shown while image thumbnail loads
│ │ │ └── video_thumbnail.png # Placeholder shown while video thumbnail loads
│ │ └── js/
│ │ ├── gallery.js # Gallery lightbox, selection, lazy loading, download
│ │ ├── upload.js # File compression + presigned S3 upload flow
│ │ ├── countdown.js
│ │ └── utils.js
│ ├── migrations/
│ ├── templates/
│ │ ├── gallery.html
│ │ ├── upload.html
│ │ └── countdown.html
│ ├── models.py # File model with thumbnail_url property
│ ├── views.py # All app views
│ ├── urls.py
│ ├── forms.py
│ └── middleware.py
├── weddingram/
│ ├── settings.py
│ ├── urls.py
│ ├── wsgi.py
│ └── celery.py
├── templates/
│ └── registration/login.html
├── Dockerfile
├── docker-compose.yml # Local development
├── docker-compose.stg.yml # Staging
├── docker-compose.prod.yml # Production (Raspberry Pi)
├── entrypoint.sh # DB migrations + collectstatic on container start
├── nginx.conf # Nginx reverse proxy config
├── nginx_static.conf # Nginx static files config
├── manage.py
└── requirements.txt
To run with Docker:
- Docker
- Docker Compose
AWS services (production only):
- S3
- Lambda × 2
- IAM
The project includes three Docker Compose configurations for different environments. All share the same Dockerfile and entrypoint.sh, which automatically runs migrations and collectstatic on startup.
docker compose up --buildUses local file storage. No AWS required. App available at http://localhost:8000.
docker compose -f docker-compose.stg.yml up --buildUses S3 for storage. Requires AWS environment variables. Useful for testing the full AWS flow before deploying to the Pi.
docker compose -f docker-compose.prod.yml up -d --buildRuns Django + Gunicorn + Nginx. Requires all environment variables below.
Create a .env.prod file (never commit this):
DEBUG=False
SECRET_KEY=your-production-secret-key
BUCKET_FILESTORE=True
AWS_ACCESS_KEY_ID=your-key-id
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_STORAGE_BUCKET_NAME=your-bucket-name
AWS_S3_REGION_NAME=eu-north-1
POSTGRES_DB=weddingram
POSTGRES_USER=weddingram
POSTGRES_PASSWORD=your-db-password
POSTGRES_HOST=db
POSTGRES_PORT=5432Then reference it in docker-compose.prod.yml:
env_file:
- .env.prodComplete all steps below before switching to production mode (BUCKET_FILESTORE=True).
⚠️ Important: Every Lambda function you create gets its own IAM execution role with minimal permissions. You must manually attach S3 permissions to each role after creation — see steps 3 and 4.
- Go to AWS Console → S3 → Create bucket
- Name it (e.g.
weddingram-media) and choose your region (e.g.eu-north-1) - Keep "Block all public access" ON — files are private and served via presigned URLs
- After creating, go to Permissions → CORS and add:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedOrigins": ["https://yourdomain.com"],
"ExposeHeaders": []
}
]The bucket will contain these folders (created automatically on first upload):
| Folder | Contents |
|---|---|
uploads/ |
Original files uploaded by guests |
thumbnails/ |
Auto-generated WebP thumbnails (300×300) |
zips/ |
Temporary ZIP files for download (auto-deleted after 1 day) |
- Go to IAM → User groups → Create group, name it
django-s3-group - Under Attach permissions policies, attach
AmazonS3FullAccess - Create the group, then go into it → Permissions → Add permissions → Create inline policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:eu-north-1:YOUR_ACCOUNT_ID:function:generate-gallery-zip"
}
]
}Replace YOUR_ACCOUNT_ID with your 12-digit AWS account ID.
- Go to IAM → Users → Create user, name it
django-s3-user - Add it to the
django-s3-groupyou just created - Go to Security credentials → Create access key → choose Application running outside AWS
- Save the Access key ID and Secret access key — you won't see the secret again
Triggered automatically by S3 whenever a file lands in uploads/. Generates a 300×300 WebP thumbnail and saves it to thumbnails/ with the same filename but .webp extension.
No Django callback needed. The thumbnail path is deterministic — Django already knows where it will be saved.
- Lambda → Create function → Author from scratch
- Name:
generate-thumbnail - Runtime: Python 3.10
- Click Create function
- Inside your function → scroll to Layers → Add a layer
- Choose Specify an ARN
- Find the Pillow ARN for your region and Python 3.10 at:
https://api.klayers.cloud/api/v2/p3.10/layers/latest/eu-north-1/htmlFind the Pillow row and copy the ARN - Paste it → Verify → Add
- Go to AWS Serverless Application Repository → Public applications
- Search for
ffmpeg-lambda-layer→ find the one by serverlesspub → Deploy - Once deployed, go back to your Lambda function → Layers → Add a layer → Custom layers → select the ffmpeg layer
- Go to Configuration → Permissions → click the execution role link
- Attach policies → Create inline policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::YOUR_BUCKET_NAME",
"arn:aws:s3:::YOUR_BUCKET_NAME/*"
]
}
]
}Configuration → General configuration → Edit:
- Timeout: 2 minutes
- Memory: 512 MB
Configuration → Triggers → Add trigger:
- Source: S3
- Bucket: your bucket
- Event type: PUT
- Prefix:
uploads/ - Acknowledge the recursive invocation warning → Add
Paste this directly into the Lambda inline editor:
import io
import os
import subprocess
import boto3
from PIL import Image
from urllib.parse import unquote_plus
s3 = boto3.client('s3')
THUMBNAIL_SIZE = (300, 300)
VIDEO_EXTENSIONS = ('.mp4', '.mov', '.avi', '.webm', '.mkv')
def get_video_thumbnail(original_bytes):
input_path = '/tmp/input_video'
output_path = '/tmp/thumb.jpg'
with open(input_path, 'wb') as f:
f.write(original_bytes)
subprocess.run([
'/opt/bin/ffmpeg',
'-i', input_path,
'-ss', '00:00:01',
'-vframes', '1',
output_path,
'-y'
], check=True)
return Image.open(output_path)
def lambda_handler(event, context):
record = event['Records'][0]['s3']
bucket = record['bucket']['name']
key = unquote_plus(record['object']['key']) # S3 URL-encodes keys in events
if not key.startswith('uploads/'):
print(f"Skipping {key} - not in uploads/")
return
print(f"Processing: {key}")
obj = s3.get_object(Bucket=bucket, Key=key)
original_bytes = obj['Body'].read()
if key.lower().endswith(VIDEO_EXTENSIONS):
img = get_video_thumbnail(original_bytes)
else:
img = Image.open(io.BytesIO(original_bytes))
img = img.convert('RGB')
img.thumbnail(THUMBNAIL_SIZE, Image.LANCZOS)
buffer = io.BytesIO()
img.save(buffer, format='WEBP', quality=75)
buffer.seek(0)
filename = key.split('/')[-1]
filename_no_ext = os.path.splitext(filename)[0]
thumb_key = f"thumbnails/{filename_no_ext}.webp"
s3.put_object(
Bucket=bucket,
Key=thumb_key,
Body=buffer.getvalue(),
ContentType='image/webp',
)
print(f"Thumbnail saved: {thumb_key}")Invoked on demand by Django when a user requests a ZIP download. Pulls the selected files from S3, zips them in memory, saves to zips/, and returns a presigned download URL valid for 1 hour.
- Lambda → Create function → Author from scratch
- Name:
generate-gallery-zip - Runtime: Python 3.10 (or any version — no extra layers needed)
- Click Create function
Same as the thumbnail Lambda — go to Configuration → Permissions → execution role → Create inline policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::YOUR_BUCKET_NAME",
"arn:aws:s3:::YOUR_BUCKET_NAME/*"
]
}
]
}Configuration → General configuration → Edit:
- Timeout: 5 minutes
Configuration → Environment variables:
| Key | Value |
|---|---|
BUCKET_NAME |
your S3 bucket name |
This function has no S3 trigger — it is invoked directly by Django via
boto3.
import boto3
import io
import os
import uuid
import zipfile
s3 = boto3.client('s3')
BUCKET = os.environ['BUCKET_NAME']
def lambda_handler(event, context):
keys = event['keys']
zip_key = f"zips/temp_{uuid.uuid4()}.zip"
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for key in keys:
obj = s3.get_object(Bucket=BUCKET, Key=key)
filename = key.split('/')[-1]
zf.writestr(filename, obj['Body'].read())
zip_buffer.seek(0)
s3.put_object(
Bucket=BUCKET,
Key=zip_key,
Body=zip_buffer.getvalue()
)
url = s3.generate_presigned_url(
'get_object',
Params={'Bucket': BUCKET, 'Key': zip_key},
ExpiresIn=3600
)
return {'download_url': url}ZIP files accumulate in zips/ after each download. This rule deletes them automatically after 1 day.
- S3 → your bucket → Management → Lifecycle rules → Create lifecycle rule
- Rule name:
delete-temp-zips - Prefix:
zips/ - Add action: Expire current versions of objects → after 1 day
- Save
| Variable | Required in prod | Description |
|---|---|---|
DEBUG |
No (defaults to False) | Django debug mode |
SECRET_KEY |
Yes | Django secret key |
BUCKET_FILESTORE |
Yes | True to use S3; False for local file storage |
AWS_ACCESS_KEY_ID |
Yes | IAM user access key |
AWS_SECRET_ACCESS_KEY |
Yes | IAM user secret key |
AWS_STORAGE_BUCKET_NAME |
Yes | S3 bucket name |
AWS_S3_REGION_NAME |
Yes | AWS region (e.g. eu-north-1) |
POSTGRES_DB |
Yes | PostgreSQL database name |
POSTGRES_USER |
Yes | PostgreSQL user |
POSTGRES_PASSWORD |
Yes | PostgreSQL password |
POSTGRES_HOST |
Yes | PostgreSQL host (e.g. db in Docker) |
POSTGRES_PORT |
Yes | PostgreSQL port (e.g. 5432) |
WEDDING_DATE |
No | If set, enables countdown page before this date |
The app uses a token-based authentication system (auth/backends.py). Guests don't log in with a username/password — instead, they receive a unique URL:
https://yourdomain.com/login_qr/<token>/
Tokens are managed via the Django admin panel (/admin/). Create a token per guest or share one for the whole event.
To create a superuser for admin access:
# Without Docker
python manage.py createsuperuser
# With Docker
docker compose -f docker-compose.prod.yml exec web python manage.py createsuperuserThe project includes two Nginx configs:
nginx.conf — reverse proxy, forwards requests to Gunicorn:
- Handles main app traffic
- Sets
client_max_body_sizeto allow large video uploads - Proxies to Django container on port 8000
nginx_static.conf — serves Django static files directly:
- Serves
STATIC_ROOTwithout hitting Django - Used in production to keep the app container lean
Both run as containers via docker-compose.prod.yml — no manual Nginx installation needed on the Pi.