Self-host MediaCMS for video hosting
Follow this on your own server. Start free, no card needed
Running your own video portal usually means choosing between a thin wrapper around someone else’s storage bill and a real media CMS that handles uploads, transcoding and playback itself. MediaCMS is the second kind: an open-source Django app that ingests video, audio and images, transcodes them to multiple resolutions with Celery workers running in the background, and serves a branded portal with its own search and playlists. By the end of this guide you’ll have the web app, a separate transcoding worker, Postgres and Redis running together behind HTTPS, with backups and a real upload tested end to end.
What you’ll need
- A server with Docker and the Compose plugin. A Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB NVMe, €8.99/month excl. VAT) is the realistic floor once transcoding is involved; a CX23 technically runs the web app, but concurrent uploads compete with the uWSGI web server for the same limited CPU, which a dedicated worker process needs room to use.
- A domain name you can point an A record at.
- Real disk planning: MediaCMS keeps the original upload plus every transcoded resolution plus HLS segments for streaming, a rough rule of thumb from the project itself is three times the size of what you actually upload, across a year of uploads at your expected rate.
- SSH access to the server.
Step 1: Point DNS and open the firewall
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable
Create an A record for your domain pointing at the server’s public IP, and give it a few minutes before Step 5.
Step 2: Generate the secrets
openssl rand -hex 24 # POSTGRES_PASSWORD
openssl rand -hex 24 # ADMIN_PASSWORD
openssl rand -hex 32 # SECRET_KEY
mkdir -p /opt/mediacms
cat > /opt/mediacms/.env <<'EOF'
POSTGRES_PASSWORD=paste-the-first-value-here
ADMIN_PASSWORD=paste-the-second-value-here
SECRET_KEY=paste-the-third-value-here
EOF
chmod 600 /opt/mediacms/.env
SECRET_KEY matters more here than in a typical Django app: left unset, MediaCMS’s own local_settings.py falls back to a hardcoded default baked into every copy of the image, which would let anyone forge signed cookies or password-reset tokens against any deployment that left it unset. Setting it explicitly, and sharing the same value across the web and worker processes, is not optional.
Step 3: Write the Compose file
MediaCMS actually needs four processes that all read the same environment: the web app, a Celery worker for transcoding, a Celery beat scheduler for periodic tasks, and the database and cache they share.
services:
mediacms:
image: mediacms/mediacms:7.2.0
restart: unless-stopped
env_file: .env
command: ./deploy/docker/prestart.sh
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
environment:
FRONTEND_HOST: https://videos.yourdomain.com
REDIS_LOCATION: redis://redis:6379/1
POSTGRES_HOST: db
POSTGRES_NAME: mediacms
POSTGRES_USER: mediacms
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ENABLE_CELERY_SHORT: "no"
ENABLE_CELERY_LONG: "no"
ENABLE_CELERY_BEAT: "no"
ADMIN_USER: admin
ADMIN_EMAIL: admin@yourdomain.com
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
SECRET_KEY: ${SECRET_KEY}
volumes:
- mediacms_media:/home/mediacms.io/mediacms/media_files
expose:
- "80"
worker:
image: mediacms/mediacms:7.2.0
restart: unless-stopped
env_file: .env
depends_on:
- mediacms
environment:
REDIS_LOCATION: redis://redis:6379/1
POSTGRES_HOST: db
POSTGRES_NAME: mediacms
POSTGRES_USER: mediacms
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
SECRET_KEY: ${SECRET_KEY}
ENABLE_UWSGI: "no"
ENABLE_NGINX: "no"
ENABLE_CELERY_BEAT: "no"
ENABLE_MIGRATIONS: "no"
volumes:
- mediacms_media:/home/mediacms.io/mediacms/media_files
beat:
image: mediacms/mediacms:7.2.0
restart: unless-stopped
env_file: .env
depends_on:
- mediacms
environment:
REDIS_LOCATION: redis://redis:6379/1
POSTGRES_HOST: db
POSTGRES_NAME: mediacms
POSTGRES_USER: mediacms
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
SECRET_KEY: ${SECRET_KEY}
ENABLE_UWSGI: "no"
ENABLE_NGINX: "no"
ENABLE_CELERY_SHORT: "no"
ENABLE_CELERY_LONG: "no"
ENABLE_MIGRATIONS: "no"
db:
image: postgres:17.2-alpine
restart: unless-stopped
environment:
POSTGRES_USER: mediacms
POSTGRES_DB: mediacms
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- mediacms_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mediacms"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7.2.5-alpine
restart: unless-stopped
volumes:
- mediacms_redis_data:/data
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- mediacms
volumes:
mediacms_media:
mediacms_db_data:
mediacms_redis_data:
caddy_data:
caddy_config:
This guide pins 7.2.0, the current stable release as of writing. The mediacms service runs prestart.sh, MediaCMS’s own entrypoint script that applies migrations and starts the actual web server, which is why ENABLE_MIGRATIONS is left unset there but explicitly "no" on worker and beat, running migrations from three processes at once on every boot is a real way to hit a race condition. ENABLE_CELERY_SHORT, ENABLE_CELERY_LONG and ENABLE_CELERY_BEAT are all "no" on the mediacms service specifically so the web container doesn’t also try to run Celery itself; those three roles are split across worker and beat instead, each disabling the role it doesn’t own in turn. mediacms_media is a single volume shared between mediacms and worker, since the worker reads what the web process accepted and writes the transcoded output both need to see.
Check the file parses:
docker compose config
Step 4: Add Caddy and start the stack
videos.yourdomain.com {
reverse_proxy mediacms:80
}
docker compose up -d
docker compose ps
curl -I https://videos.yourdomain.com
You should see every service Up (db as healthy) and the curl return HTTP/2 200.
Step 5: Log in and upload a test video
Visit https://videos.yourdomain.com and log in with admin and the ADMIN_PASSWORD from Step 2. Upload a short test video from the UI, then check it actually transcoded, not just uploaded:
docker compose logs -f worker
You should see Celery task output referencing the new media’s ID, ending in a completed state, and the video itself should be playable in the browser a minute or two later once the worker catches up, longer for a larger file.
Step 6: Back up Postgres and the media volume
cat > /opt/mediacms/backup.sh <<'EOF'
#!/bin/sh
cd /opt/mediacms
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U mediacms mediacms | gzip > backups/db-$TIMESTAMP.sql.gz
find backups -mtime +14 -delete
EOF
mkdir -p /opt/mediacms/backups
chmod +x /opt/mediacms/backup.sh
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/mediacms/backup.sh") | crontab -
The database dump covers metadata, accounts and playlists, not the actual video files on mediacms_media, which is almost certainly the larger and more valuable part of this install. Back that volume up with a full-disk snapshot from your server provider, or a scheduled rsync to another machine; running a tar across live media while uploads are in progress risks an inconsistent archive, so schedule it for a quiet period the same way Step 6 in other guides handles an active database.
Step 7: Upgrade
docker compose pull
docker compose up -d
The mediacms service’s prestart.sh reapplies migrations automatically on restart; worker and beat pick up the new image too since they share it, but intentionally skip migrations themselves, as set in Step 3. Check docker compose logs mediacms for a clean startup before considering the upgrade done.
The shortcut: do it with Railyard
Everything above, Postgres and Redis wired to all three processes, the worker and beat roles correctly split so they don’t double-run Celery or race on migrations, a shared media volume, is also what Railyard’s MediaCMS stack template sets up for you. Connect a server, then pick MediaCMS in the templates catalog: app.railyard.run/deploy?stack=mediacms. SECRET_KEY and ADMIN_PASSWORD are generated for you rather than left as placeholders, the generated admin password shows up on the app’s dashboard instead of only printing once to a log the way MediaCMS’s own prestart.sh does when left unset.
See deploy with a button and databases and backups for how templates and persistent data work on the platform more generally.
Troubleshooting
Uploads succeed but videos never finish processing. Check docker compose logs worker first; a worker that’s crash-looping or OOM-killed shows up as the container restarting rather than a clear error in the log, in which case the fix is more memory or CPU for the box, not a config change.
The admin login rejects a password you’re sure is correct. Confirm ADMIN_PASSWORD in .env matches what you’re typing exactly; if the stack was already running before you set it, the account was created with whatever value was present on first boot, change it from the Django admin instead of re-editing .env after the fact.
Migrations seem to run twice or conflict on startup. Confirm ENABLE_MIGRATIONS: "no" is actually present on both worker and beat in your compose file; if it’s missing on either, you’ll see intermittent migration errors that look unrelated to the actual cause.
HTTPS not issuing. docker compose logs caddy showing repeated ACME errors almost always means DNS from Step 1 hasn’t propagated or points at the wrong IP.
Disk filling up much faster than expected. MediaCMS keeps the original upload, transcoded renditions, and HLS segments side by side; du -sh against the media volume shows actual usage, and the three-times-upload-size rule of thumb from “What you’ll need” is worth revisiting against your own real upload rate rather than assumed once and forgotten.
FAQ
Can I disable transcoding and just serve the original file? Not meaningfully, adaptive streaming (serving the right resolution for the viewer’s connection) is a core feature, not an add-on; if you only need to host a handful of pre-encoded files without transcoding, a plain static file host is a better fit than MediaCMS.
Does MediaCMS need an SMTP provider? Only for optional features like email notifications on comments; account creation and core video hosting both work without it, less strict than apps that require SMTP to confirm a new account.
Can the worker run on a separate, cheaper server from the web process? Yes, as long as it can reach the same Postgres, Redis and shared media storage, splitting the worker onto its own machine is a reasonable next step once transcoding load genuinely competes with web traffic for CPU.
What video formats does it accept? MediaCMS accepts common formats like MP4, MOV, MKV, AVI, WebM and more on upload, and transcodes them to standard web-friendly output regardless of the source format.
Is there a size limit on uploads? MediaCMS’s own default is generous but configurable; a small server’s disk space is the more practical limit in a self-hosted setup than anything MediaCMS enforces by default.
Once Postgres, Redis and the worker are all wired to the same secrets, uploading and transcoding a video is the same few clicks it would be on a hosted platform. The real ongoing work is the backup habit from Step 6 and keeping disk growth ahead of what the server actually has.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.