Installing LCOJ with Docker
Bring up a complete LCOJ instance (web app, database, cache, judge bridge, WebSocket) on a Linux server with Docker Compose, in 7 steps.
⏱ ~60 min (the image build alone takes 10–20 min) · 👤 Operators · 🔑 SSH access to a Linux server with
sudo
This page walks you through a fresh LCOJ install with lcoj-docker on a VPS with a public IP. Everything (web app, database, cache, judge bridge, WebSocket server) runs under Docker Compose, so you don't need Python or MariaDB on the host.
Judges are installed separately
This Compose stack does not include a judge. It only runs bridged, which judges connect to. Once the site is up, see Judge Setup.
Before you start
| Minimum | Recommended | |
|---|---|---|
| CPU | 2 cores | 4+ cores |
| RAM | 4 GB | 8 GB+ |
| Disk | 20 GB free | 50 GB+ SSD (test data grows over time) |
| OS | 64-bit Linux (Ubuntu 22.04+ is easiest) |
Software: Docker with the Docker Compose v2 plugin (the docker compose command, not docker-compose) and Git.
The diagram below shows the services you'll bring up. See Architecture for details on each one.
Step 1: Install Docker
On Ubuntu/Debian:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Let the current user run docker without sudo
sudo usermod -aG docker $USER
# Log out and back in for the group change to take effectVerify:
docker --version
docker compose versionStep 2: Get the source
git clone --recursive https://github.com/luyencode/lcoj-docker.git
cd lcoj-docker/dmoj--recursive is required: the Django code lives in the dmoj/repo submodule (the lcoj-site repo). If you forgot it, run git submodule update --init --recursive.
TIP
From here on, run every command from the dmoj/ directory. The scripts in scripts/ and all docker compose commands expect it.
Step 3: Run the init script
./scripts/initializeThe script does exactly two things:
- Creates the
problems/(test data) andmedia/(user uploads) directories. - Copies the config templates from
config/into the source tree:
| Source | Destination | Used by |
|---|---|---|
config/local_settings.py | repo/dmoj/local_settings.py | Django settings |
config/uwsgi.ini | repo/uwsgi.ini | uWSGI (worker count, etc.) |
config/config.js | repo/websocket/config.js | WebSocket server |
WARNING
Re-running initialize overwrites the three destination files above. Back them up first if you've edited them.
See Helper Scripts for the other scripts.
Step 4: Configure
4.1. Create the environment files
The templates ship in environment/:
cp environment/mysql-admin.env.example environment/mysql-admin.env
cp environment/mysql.env.example environment/mysql.env
cp environment/site.env.example environment/site.envThe *.env files are excluded by .gitignore, so they never get committed.
4.2. Database
These two files configure MariaDB. site, celery and bridged also read mysql.env to connect, so do not repeat the MYSQL_* variables in site.env.
MYSQL_HOST=db
MYSQL_DATABASE=dmoj
MYSQL_USER=dmoj
MYSQL_PASSWORD=<strong password>MYSQL_ROOT_PASSWORD=<a different root password>MariaDB only creates the database and user from these variables on first start, while the database/ directory is still empty. Changing the password later has to be done in SQL; see Operations.
4.3. Site
A minimal environment/site.env for an install served at lcoj.example.com (use your own domain):
HOST=lcoj.example.com
SITE_FULL_URL=https://lcoj.example.com/
MEDIA_URL=https://lcoj.example.com/
DEBUG=0
SECRET_KEY=<long random string>
EVENT_DAEMON_POST=ws://wsevent:15101/
REDIS_CACHING_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/1
CELERY_RESULT_BACKEND=redis://redis:6379/1
BRIDGED_HOST=bridged
# Google sign-in (required for new users to register, see 4.4)
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=<client id>
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=<client secret>Common pitfalls:
DEBUGis on only when the value is exactly1.Truecounts as off. Production should always use0.HOSTis the bare domain (nohttps://, no port). It becomesALLOWED_HOSTSand is used to build the WebSocket URLs. For a local test, uselocalhostand set both URLs tohttp://localhost:8071/.SITE_FULL_URLandMEDIA_URLusehttps://when the site runs behind an HTTPS reverse proxy (see HTTPS on a VPS).SITE_NAME,SITE_LONG_NAMEandSITE_ADMIN_EMAILare not environment variables. They're hardcoded inlocal_settings.py.- The Redis, Celery, WebSocket and bridge values above match the service names in
docker-compose.yml. Leave them as-is unless you've changed the stack.
Generate a SECRET_KEY:
python3 -c "import secrets; print(secrets.token_urlsafe(50))"For the full list of variables (including MOSS_API_KEY and NGINX_PORT), see Environment Variables.
NGINX_PORT is not read from site.env
docker-compose.yml publishes nginx on ${NGINX_PORT:-8071}. Compose substitutes that variable from your shell or from a dmoj/.env file, not from environment/site.env. To change the port, create dmoj/.env containing NGINX_PORT=8080 (or export NGINX_PORT=8080 before running commands), then run docker compose up -d nginx. If nothing is set, the port is 8071.
4.4. Google sign-in (OAuth)
The bundled local_settings.py sets OAUTH_ONLY = True. That hides the password-based sign-up form, so new users can only register with Google. The username/password login form is still there, so admin accounts created from the command line can log in normally.
To get the keys:
- In the Google Cloud Console, create an OAuth client ID of type Web application.
- Add the Authorized redirect URI
https://lcoj.example.com/complete/google-oauth2/(use your own domain). - Put the Client ID and Client secret into
SOCIAL_AUTH_GOOGLE_OAUTH2_KEYandSOCIAL_AUTH_GOOGLE_OAUTH2_SECRETinsite.env.
4.5. Nginx
In nginx/conf.d/nginx.conf, change server_name (luyencode.net by default) to your domain:
server {
listen 80;
server_name lcoj.example.com; # your domain
# ... leave the rest unchanged
}The containerized nginx only listens for HTTP on port 80. HTTPS is handled by a reverse proxy on the host; see HTTPS on a VPS.
Step 5: Build the images
The lcoj/lcoj-base image holds Python, Node.js and all dependencies (requirements.txt, package.json). The site, celery and bridged images are built on top of it, so build base first:
docker compose build base
docker compose buildThe first build takes roughly 10–20 minutes depending on your network.
Step 6: Initialize the database and static files
Start the services you need:
shdocker compose up -d site db redis celeryOn first start MariaDB needs some time to create the database. Watch
docker compose logs -f dbuntil you seeready for connections.Create the tables:
sh./scripts/migrateBuild the CSS and collect static files (compiles SCSS, runs
collectstatic, compiles translations, copies everything to theassetsvolume nginx serves):sh./scripts/copy_staticLoad the initial data:
sh./scripts/manage.py loaddata navbar ./scripts/manage.py loaddata language_small ./scripts/manage.py loaddata demoFixture Contents navbarDefault navigation menu language_smallA handful of common languages (use language_allfor the full set)demoSample data, including an adminaccount with passwordadminChange the admin password
The
demofixture creates the superuseradmin/admin. On a public server, change its password or delete it right away, or skip thedemofixture.Create your own admin account:
sh./scripts/manage.py createsuperuserLog in at
/accounts/login/with that username and password. Google isn't needed.
Step 7: Start everything
docker compose up -d
docker compose psThe lcoj_site, lcoj_celery, lcoj_bridged, lcoj_wsevent, lcoj_mysql, lcoj_redis and lcoj_nginx containers should all be Up. The base service only exists to build the shared image and exits right after starting, which is expected.
Verify
Every container is Up in
docker compose ps(exceptbase, as noted above).nginx answers on the server:
shcurl -I http://localhost:8071/Open
http://<server-ip>:8071/in a browser to see the LCOJ home page (ifHOSTis already set to a domain, see Testing without a domain to browse by IP). If you loadeddemo, go to Admin → Sites and change the default domain (localhost:8081) to your real one.Log in at
/accounts/login/with the account you created in Step 6 and open/admin/.No judge yet is expected: submissions are only graded once you connect a judge.
Ports
| Service | Container | Port | Published on the host? |
|---|---|---|---|
| nginx | lcoj_nginx | ${NGINX_PORT:-8071} → 80 | Yes |
| bridged | lcoj_bridged | 9999 (judges connect), 9998 (site-to-bridge) | Yes |
| site | lcoj_site | 8000 (uwsgi) | No |
| wsevent | lcoj_wsevent | 15100, 15101, 15102 | No, reached through nginx /event/ and /channels/ |
| db | lcoj_mysql | 3306 | No |
| redis | lcoj_redis | 6379 | No |
| celery | lcoj_celery | — | No |
Firewall
Only judges need port 9999. Port 9998 and the nginx port (8071) don't need to be reachable from the Internet. Note that Docker-published ports bypass ufw rules. See Step H2 and Step H6 for how to restrict them.
HTTPS on a VPS
The Docker nginx serves plain HTTP only: port 80 inside the container, published on the host as ${NGINX_PORT:-8071}. To go public over HTTPS, run a TLS reverse proxy directly on the VPS. It terminates HTTPS on ports 80/443, gets Let's Encrypt certificates automatically, and forwards to 127.0.0.1:8071:
The steps below use the example domain lcoj.example.com and the default port 8071. Substitute your own values.
Don't run certbot against the containerized nginx
The container's nginx config lives inside Docker and has no port 443. Certificates must be managed by the proxy on the host.
Step H1: Point your domain at the VPS
At your DNS provider, create an A record pointing lcoj.example.com at the VPS's public IPv4 address (and an AAAA record if the VPS has IPv6). Check it:
dig +short lcoj.example.comIt should print the VPS's IP. Let's Encrypt only issues a certificate once the domain resolves correctly and port 80 on the VPS is reachable from the Internet.
Step H2: Bind the nginx port to localhost only
By default Compose publishes nginx on every host address, so anyone can reach http://<VPS-IP>:8071 and bypass HTTPS. Create dmoj/docker-compose.override.yml (Compose reads it automatically alongside docker-compose.yml):
services:
nginx:
ports: !override
- "127.0.0.1:${NGINX_PORT:-8071}:80"
bridged:
ports: !override
- "127.0.0.1:9998:9998"
- "9999:9999"!overridereplaces the originalportslist instead of appending to it. It requires Docker Compose v2.24.4 or later (docker compose version).- Port 9998 is only used between
siteandbridged, so binding it to127.0.0.1is enough. - If every judge runs on this same VPS (
--network=host, connecting tolocalhost:9999), change the last line to"127.0.0.1:9999:9999". If some judges run on other machines, keep"9999:9999"and restrict it by IP in Step H6.
Apply and check:
docker compose up -d nginx bridged
docker compose ps nginx bridgedThe PORTS column for nginx should show 127.0.0.1:8071->80/tcp.
Step H3: Install a TLS reverse proxy
Pick one of the two options. Both need ports 80 and 443 free on the VPS, so don't set NGINX_PORT to 80 or 443.
Option A: Caddy (simplest)
Caddy obtains and renews certificates, redirects HTTP to HTTPS, proxies WebSockets, and sets the X-Forwarded-For and X-Forwarded-Proto headers, all out of the box.
Install Caddy on Ubuntu/Debian (from the official docs):
shsudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list sudo apt update sudo apt install caddyReplace the contents of
/etc/caddy/Caddyfilewith:txtlcoj.example.com { reverse_proxy 127.0.0.1:8071 }That single
reverse_proxyline covers both the website and the/event/WebSocket.Reload the config and watch the certificate being issued:
shsudo systemctl reload caddy sudo journalctl -u caddy -f
Option B: host nginx + certbot
Install nginx and certbot:
shsudo apt install -y nginx certbot python3-certbot-nginxCreate
/etc/nginx/sites-available/lcoj:nginxserver { listen 80; listen [::]:80; server_name lcoj.example.com; client_max_body_size 64M; location / { proxy_pass http://127.0.0.1:8071; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 600; } # Live-update WebSocket location /event/ { proxy_pass http://127.0.0.1:8071; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; } }client_max_body_size 64Mandproxy_read_timeout 600match the limits of the containerized nginx, so large test-data uploads and long requests aren't cut off by the proxy.Enable the site and reload nginx:
shsudo ln -s /etc/nginx/sites-available/lcoj /etc/nginx/sites-enabled/lcoj sudo rm -f /etc/nginx/sites-enabled/default sudo nginx -t && sudo systemctl reload nginxGet a certificate. certbot adds
listen 443 ssland an HTTP-to-HTTPS redirect to the file above:shsudo certbot --nginx -d lcoj.example.com sudo certbot renew --dry-run # check that automatic renewal works
Step H4: Switch site.env to https
In environment/site.env:
HOST=lcoj.example.com
SITE_FULL_URL=https://lcoj.example.com/
MEDIA_URL=https://lcoj.example.com/HOST has no https:// and no port. Run docker compose up -d (not restart) so the containers pick up the new values.
You don't need to set the WebSocket URLs separately: local_settings.py builds EVENT_DAEMON_GET = 'ws://<HOST>/event/' and EVENT_DAEMON_GET_SSL = 'wss://<HOST>/event/' from HOST. The site uses the wss:// URL when it recognizes the request as HTTPS, which requires SECURE_PROXY_SSL_HEADER from the next step.
Step H5: Configure Django for HTTPS
Open repo/dmoj/local_settings.py (the running copy) and add at the end:
# Running behind an HTTPS reverse proxy
CSRF_TRUSTED_ORIGINS = ['https://lcoj.example.com']
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')Then restart the site:
docker compose restart siteCSRF_TRUSTED_ORIGINSis required. Django checks theOriginheader of every form submission. Without this setting, forms posted fromhttps://lcoj.example.comare rejected. LCOJ's CSRF failure handler just redirects back to the same page without an error message, so the symptom is clicking Submit, Save or Log in only reloads the page and nothing changes. If you serve another domain too (such aswww), add it to this list and toALLOWED_HOSTS.SECURE_PROXY_SSL_HEADERis recommended. It tells Django the original request was HTTPS, based on theX-Forwarded-Protoheader. Without it, HTTPS pages open the WebSocket overws://, the browser blocks it as mixed content, and submission results stop updating live. Only enable it when the container's nginx port is not reachable from outside (Step H2) and the proxy always sets this header. Caddy does so by default; the nginx config in Option B setsX-Forwarded-Proto $scheme. Otherwise anyone could forge the header to make Django treat a request as HTTPS.
Keep these settings when re-running initialize
./scripts/initialize copies config/local_settings.py over repo/dmoj/local_settings.py. Add the two lines above to config/local_settings.py too so they aren't lost.
Step H6: Firewall
| Port | Open to the Internet? | Notes |
|---|---|---|
| 22 | Yes | SSH |
| 80, 443 | Yes | Reverse proxy. Port 80 is needed to obtain/renew certificates and to redirect to HTTPS |
8071 (NGINX_PORT) | No | 127.0.0.1 only (Step H2) |
| 9998 | No | Only used between site and bridged |
| 9999 | Only if judges run on other machines | Restrict to the judges' IPs |
| 3306, 6379 | — | db and redis aren't published on the host |
With ufw:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableufw can't block Docker-published ports
Docker adds its own iptables rules for everything under ports:, so ufw has no effect on 8071, 9998 or 9999. Bind them to 127.0.0.1 instead (Step H2). To restrict port 9999 to your judges' IPs, use your VPS provider's firewall (the easiest option) or the iptables DOCKER-USER chain:
# eth0 is the public interface; <judge-ip> is the allowed address
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 9999 --ctdir ORIGINAL ! -s <judge-ip> -j DROPThis iptables rule is lost on reboot; use the iptables-persistent package to save it.
Verify HTTPS
curl -I https://lcoj.example.com/returns200, andcurl -I http://lcoj.example.com/returns a redirect (301/308) tohttps://.- From another machine,
curl -m 5 http://<VPS-IP>:8071/fails (times out or is refused). - Log in, edit your profile and save: the change sticks.
- Open a submission page, then DevTools → Network → filter WS: the
wss://lcoj.example.com/event/connection has status101.
Testing without a domain
Before you have a domain, you can try the site by IP over plain HTTP (unencrypted, for testing only). Do this before Step H2, since port 8071 must be reachable from outside. In environment/site.env:
HOST=<VPS-IP>
SITE_FULL_URL=http://<VPS-IP>:8071/
MEDIA_URL=http://<VPS-IP>:8071/Run docker compose up -d, then open http://<VPS-IP>:8071/. In this mode:
- Forms work without
CSRF_TRUSTED_ORIGINS, because the browser sendsOrigin: http://..., which matches the HTTP request. Don't enableSECURE_PROXY_SSL_HEADER. - The WebSocket URL built from
HOSThas no port (ws://<VPS-IP>/event/). For live updates to work, addEVENT_DAEMON_GET = 'ws://<VPS-IP>:8071/event/'at the end ofrepo/dmoj/local_settings.pyand rundocker compose restart site. Remove that line when you switch to a domain. - Your VPS provider's firewall may block port
8071; if so, open it temporarily.
Once you have a domain, go through Steps H1 to H6.
Performance tuning
- uWSGI workers (web requests): change
workers = 8inrepo/uwsgi.ini(the template isconfig/uwsgi.ini), thendocker compose restart site. Each worker may use up to 512 MB of RAM before it's recycled (reload-on-rss = 512M). - Celery concurrency:
--concurrency=2is set in theENTRYPOINTofcelery/Dockerfile. Change it there, then rundocker compose up -d --build celery.
Go-live checklist
Troubleshooting
| Symptom | Fix |
|---|---|
permission denied when running docker | You haven't logged out and back in after usermod -aG docker (Step 1) |
dmoj/repo is empty, the build complains about missing files | You cloned without --recursive: run git submodule update --init --recursive |
./scripts/migrate can't connect to the database | MariaDB is still initializing: wait for ready for connections in docker compose logs -f db, then retry |
Building site/celery/bridged can't find lcoj/lcoj-base | Run docker compose build base first (Step 5) |
| The site loads without CSS | Re-run ./scripts/copy_static |
| Clicking Submit / Save / Log in does nothing, the page just reloads | CSRF_TRUSTED_ORIGINS is missing or wrong in repo/dmoj/local_settings.py: it must contain exactly https://<your-domain>. Then run docker compose restart site (Step H5) |
Results don't update live; the browser console shows Mixed Content or ws:// errors | SECURE_PROXY_SSL_HEADER is missing (Step H5), or the proxy doesn't forward the Upgrade/Connection headers for /event/ |
| Caddy/certbot can't obtain a certificate | The domain doesn't resolve to the VPS IP yet (dig +short <your-domain>), or your provider's firewall blocks ports 80/443 |
| The host proxy returns 502 | The nginx container isn't running or the port is wrong: run curl -I http://127.0.0.1:8071/ on the VPS |
| 502 Bad Gateway | site is still starting or failed to load Django: check docker compose logs --tail=100 site (details) |
| 400 Bad Request | HOST in site.env doesn't match the domain you're browsing |
| Port 8071 is already in use | Change it with NGINX_PORT in dmoj/.env (see the warning in Step 4.3) |
Edits to site.env have no effect | Run docker compose up -d (not restart) to recreate the containers |
Next steps
- Judge Setup: connect a judge so submissions get graded.
- Site configuration: set your domain, menu and home page content.
- Day-to-day Operations: restarts, logs, backups.
- Reference: Environment Variables, Helper Scripts, Updating LCOJ.
Need help?
Open an issue on lcoj-docker, or reach us via behitek.com or luyencode.net/about/#lien-he.
