Setting Up Multiple CTFd Instances with Docker, Digital Ocean, and Cloudflare
Our first dive into docker, challenges and lessons learned, a full guide on infophreak's CTFd infrastructure. - Blog by SH3LL
Setting Up Multiple CTFd Instances with Docker, Digital Ocean, and Cloudflare.
Our first dive into docker, challenges and lessons learned, a full guide on infophreak's CTFd infrastructure. - Blog by SH3LL
Connect with me!INTRODUCTION
Welcome to my first infophreak blog! We will be going through a full CTFd setup from start to finish, including a solution for multiple CTFd instances on the same droplet.
If you found this blog because you are having issues with NGINX, then you are in the right place. We had many issues trying to get NGINX setup for multiple docker applications and found that you can really only have one dockerized NGINX instance running on a single host. More specifically, our issue was related to request handling on one CTFd site bleeding onto the other and vice versa. I believe this has something to do with how the docker engine handles mapping to the host ports, since both NGINX apps need host port 80 and 443. I could not figure out how to get two entirely separate CTFd docker apps running at the same time, each with their own NGINX. From a logical standpoint, after pulling my hair out for some time, this makes sense. We were able to solve this issue by combining the two CTFd instances in one docker application in order to share one NGINX app.
In this blog, we are going to use the following:
- A Digital Ocean Ubuntu Droplet
- Cloudflare Reverse Proxy/Origin Certificates
- CTFd Official Docker Applications
This guide assumes you have a decent understanding of Linux, a good amount of patience, and some basic troubleshooting skills. I will try to lay everything out for you the best I can.
Additionally, I will provide some basic steps to harden your droplet and docker setup. This is by no means the most secure build, but a decent baseline based on what I've learned. I highly recommend you do additional research to further secure your setup as needed such as SSH MFA, IPS/IDS, etc..
DEPLOY DROPLET
- If you haven't already, create a Digital Ocean account. Once logged in, on your dashboard find the "Droplets" tab, under the "Manage" category, on the lefthand navigation plane.
Creation
- Next, lets click "Create Droplet."
- Choose the closest region to you, or the region where a majority of your users are located.
- Under "Choose an image" we are going to use Ubuntu 24.04 (LTS) x64 for this guide.
- Under "Droplet Type" we used "Basic" (optional).
- Under "CPU options" we chose "Premium AMD" (optional) and 2GBmem/1cpu (minimum).
- Under "Choose Authentication Method" select "SSH Key"
Let's create a key
On the device you are going to use to access your droplet, use this guide to create a key pair. This works on both Linux CLI (Command-line interface) and Windows Powershell and is a modern, secure key that works on most systems.
Upload the public key
Now that the keys have been generated and the private key has been added to the authentication agent, we can copy the contents of our public key and add it to the Digital Ocean droplet setup.
- Click on "New SSH Key" in the droplet setup menu.
- Paste the content of your full public key, making sure there is no whitespace or additional formatting.
- Add a friendly name.
- Click "Add SSH Key"
Finalize
Now lets finish up our setup with the last few options.
- Under "We recommend these options" I recommend selecting "Add improved metrics monitoring and alerting (free)"
- Under "Hostname" choose your desired droplet name.
- Click "Create Droplet" at the bottom right.
Access
Now, from your local device, open up a powershell or terminal window as admin and connect to your droplet. In your Digital Ocean panel, you will see an IP for your Droplet. Use the IP in the following command:
ssh root@IP
if you run into issues, try to manually provide your private key (not the one that ends in .pub) with the following command:
Linux
ssh -i "/path/to/private/key" root@IP
Windows
ssh -i "\path\to\private\key" root@IP
Now, if you password-protected your private key, supply the password and you should now have a root shell on your droplet.
HARDENING DROPLET
My basic hardening guide can be referenced here.
Follow everything but ignore the "UFW setup" portion of that hardening guide and, instead, use this one:
UFW setup
We are going to be using UFW for this guide, it is easy to use and setup and does its job well. However, since most of our traffic is going to be going in and out of docker, setting up the firewall rules is a little tricky unless you want to do them by hand. For nginx, we will have to install it on the host in order to add the firewall rules. Once we've added the rules, we can remove nginx.
- Install ufw if not already installed.
sudo apt install ufw
- Install nginx.
sudo apt install nginx
- Install openssl
sudo apt install openssl
- Allow ssh.
sudo ufw allow ssh
- Allow nginx.
sudo ufw allow 'NGINX Full'
- Enable ufw.
sudo ufw enable
- Uninstall nginx.
sudo apt purge nginx nginx-common -y
- Do not disconnect from your SSH session in case something went wrong. This is a good time to open a new terminal and verify you can still connect via SSH.
CLOUDFLARE CERTS, DNS, REVERSE PROXY
If you haven't already created a cloudflare account, you can create one here
Once logged in, you will need to add your domain to cloudflare. You can reference their documentation.
Once your site is added, navigate to your website in the cloudflare dashboard.
- Under the DNS tab, create an "A" record pointing to the IP address of your droplet. If you are only planning to host one CTFd application with your root domain, use the "@" symbol or put your full domain name (ex: domain.com) under the "name" section. If you plan to have multiple CTFd applications, each on their own subdomain, each subdomain will need its own "A" record. The "name" is just the subdomain portion, not the full domain (ex: sub.domain.com, you would just put "sub" in the name section). Make sure that the proxy is toggled on whether you are doing a single root domain or subdomains.
- Under the SSL/TLS main tab, make sure current encryption mode is set to "Full (strict)". Then navigate to the "Edge Certificates" child tab and enable "Always use HTTPS."
- Still under the SSL/TLS section, find the "Origin Server" tab. Create a new origin certificate. You can use a wildcard
*.domain.com
to cover all your subdomains. During setup, make sure you download or copy the contents of the certificate and the key and name them accordingly because we will need to be able to distinguish between the key and the origin cert later.
SETTING UP CTFd DOCKER APPLICATIONS
Since docker changes quite frequently, I'm going to refer you to the official documentation for installation. You will need to install docker engine and docker compose. Since docker does change, and there are multiple ways to install it, you may have to transpose some of these commands to the correct/modern syntax depending on when you are reading this guide.
Initial CTFd Environment
Now that docker engine and docker compose are installed, we can work on getting CTFd setup. There are many guides out there for setting up one instance of CTFd, but this guide in particular is to help someone get multiple instances up and running on the same droplet. That being said, the following instructions may be a bit confusing since we are basically going to clone the repo three times. We technically only need to clone twice if we isolate what exactly is needed for each CTFd instance, but I didn't have the time to go through and try and figure that out and I didnt want to break something. If you figure out a cleaner and more efficient method, do please leave a comment and let me know.
Create a docker user and directory setup
Lets first create a docker user so the containers arent running under a privileged user.
- Create new dockeruser and add to docker group.
sudo adduser dockeruser
sudo usermod -a -G docker dockeruser
- Create a CTFd directory and add correct permissions/ownership for our dockeruser.
sudo mkdir /etc/docker
sudo mkdir /etc/docker/CTFd
sudo chown dockeruser:dockeruser /etc/docker
sudo chown dockeruser:dockeruser /etc/docker/CTFd
sudo chmod 770 /etc/docker
sudo chmod 770 /etc/docker/CTFd
- Install git if not already installed.
apt install git
- Switch to the docker user and go to the CTFd directory.
sudo su dockeruser
cd /etc/docker/CTFd
- Clone CTFd repo.
cd /etc/docker/CTFd
git clone https://github.com/CTFd/CTFd.git .
- Create a new directory for each CTFd instance. I chose "opt."
mkdir /etc/docker/CTFd/opt
mkdir /etc/docker/CTFd/opt/ctfd1
mkdir /etc/docker/CTFd/opt/ctfd2
- Clone CTFd repo in each directory.
cd /etc/docker/CTFd/opt/ctfd1
git clone https://github.com/CTFd/CTFd.git .
cd /etc/docker/CTFd/opt/ctfd2
git clone https://github.com/CTFd/CTFd.git .
Your directory structure should now look like this:
etc
└── docker
└── CTFd (repo)
└── opt
├── ctfd1 (repo)
└── ctfd2 (repo)
- Find the PUID of the dockeruser and take note of it. We will need it for the docker-compose.yml.
id -u dockeruser
- Find the PGID of the dockeruser and take note of it. We will need it for the docker-compose.yml.
id -g dockeruser
docker-compose.yml Setup
Since we have cloned the repo three times, we need to be sure that we operate from the root directory of CTFd: /etc/docker/CTFd
. You could technically remove the docker-compose.yml from /etc/docker/CTFd/opt/ctfd1
& /etc/docker/CTFd/opt/ctfd2
so you don't get confused and accidentally spin up another docker application.
- Navigate to root directory of CTFd.
cd /etc/docker/CTFd
- Edit the
docker-compose.yml
file.
nano docker-compose.yml
Here is an example of how we have our docker compose yaml configured:
version: '3.8'
services:
ctfd1:
build: ./opt/ctfd1
restart: always
ports:
- "127.0.0.1:8000:8000"
environment:
- PUID=1000 # User ID (replace with dockeruser PUID)
- PGID=1000 # Group ID (replace with dockeruser PGID)
- SECRET_KEY=supersecretrandomizedkey # replace
- UPLOAD_FOLDER=/var/uploads
- DATABASE_URL=mysql+pymysql://ctfd:ctfd@db1/ctfd
- REDIS_URL=redis://cache1:6379
- WORKERS=10
- LOG_FOLDER=/var/log/CTFd
- ACCESS_LOG=-
- ERROR_LOG=-
- REVERSE_PROXY=true
volumes:
- ./opt/ctfd1/.data/CTFd/logs:/var/log/CTFd
- ./opt/ctfd1/.data/CTFd/uploads:/var/uploads
- ./opt/ctfd1:/opt/CTFd:ro
depends_on:
- db1
- cache1
networks:
- default
- internal
ctfd2:
build: ./opt/ctfd2
restart: always
ports:
- "127.0.0.1:9000:8000"
environment:
- PUID=1000 # User ID (replace with dockeruser PUID)
- PGID=1000 # Group ID (replace with dockeruser PGID)
- SECRET_KEY=supersecretrandomizedkey # replace
- UPLOAD_FOLDER=/var/uploads
- DATABASE_URL=mysql+pymysql://ctfd:ctfd@db2/ctfd
- REDIS_URL=redis://cache2:6379
- WORKERS=10
- LOG_FOLDER=/var/log/CTFd
- ACCESS_LOG=-
- ERROR_LOG=-
- REVERSE_PROXY=true
volumes:
- ./opt/ctfd2/.data/CTFd/logs:/var/log/CTFd
- ./opt/ctfd2/.data/CTFd/uploads:/var/uploads
- ./opt/ctfd2:/opt/CTFd:ro
depends_on:
- db2
- cache2
networks:
- default
- internal
nginx:
image: nginx:stable
restart: always
environment:
- PUID=1000 # User ID (replace with dockeruser PUID)
- PGID=1000 # Group ID (replace with dockeruser PGID)
volumes:
- ./conf/nginx/http.conf:/etc/nginx/nginx.conf
- ./conf/nginx/origin.pem:/certificates/origin.pem
- ./conf/nginx/key.pem:/certificates/key.pem
ports:
- 80:80
- 443:443
depends_on:
- ctfd1
- ctfd2
db1:
image: mariadb:10.11
restart: always
environment:
- PUID=1000 # User ID (replace with dockeruser PUID)
- PGID=1000 # Group ID (replace with dockeruser PGID)
- MARIADB_ROOT_PASSWORD=ctfd
- MARIADB_USER=ctfd
- MARIADB_PASSWORD=ctfd
- MARIADB_DATABASE=ctfd
- MARIADB_AUTO_UPGRADE=1
volumes:
- ./opt/ctfd1/.data/mysql:/var/lib/mysql
networks:
internal:
# This command is required to set important mariadb defaults
command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --wait_timeout=28800, --log-warnings=0]
db2:
image: mariadb:10.11
restart: always
environment:
- PUID=1000 # User ID (replace with dockeruser PUID)
- PGID=1000 # Group ID (replace with dockeruser PGID)
- MARIADB_ROOT_PASSWORD=ctfd
- MARIADB_USER=ctfd
- MARIADB_PASSWORD=ctfd
- MARIADB_DATABASE=ctfd
- MARIADB_AUTO_UPGRADE=1
volumes:
- ./opt/ctfd2/.data/mysql:/var/lib/mysql
networks:
internal:
# This command is required to set important mariadb defaults
command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --wait_timeout=28800, --log-warnings=0]
cache1:
image: redis:4
restart: always
environment:
- PUID=1000 # User ID (replace with dockeruser PUID)
- PGID=1000 # Group ID (replace with dockeruser PGID)
volumes:
- ./opt/ctfd1/.data/redis:/data
networks:
internal:
cache2:
image: redis:4
restart: always
environment:
- PUID=1000 # User ID (replace with dockeruser PUID)
- PGID=1000 # Group ID (replace with dockeruser PGID)
volumes:
- ./opt/ctfd2/.data/redis:/data
networks:
internal:
networks:
default:
internal:
internal: true
The pros to this configuration are that each CTFd instance, database, and redis cache server are on an internal docker network and mapped only to a port accessible on localhost and is not exposed to the outside world. I designed the config this way so that no one can bypass nginx. They are forced to go through nginx in order to route to the localhost.
Make sure to add your own secret keys for each CTFd environment and change passwords/usernames if you wish. Additionally, confirm that all your paths are correct and mapped respectively to each CTFd ./opt location. ctfd1, db1, and cache1 directories should all be inside
./opt/ctfd1
and ctfd2, db2, and cache2 inside./opt/ctfd2
.
Configuring nginx
You may have noticed in the docker-compose.yml file that the nginx directory is mapped to /etc/docker/CTFd/conf
. Remember, since we have three clones, each one has the same directories. Be sure you are only operating from the root conf folder. Again, you can remove the conf directory from /opt/ctfd1
and /opt/ctfd2
to avoid confusion.
- Navigate to the nginx directory.
cd /etc/docker/CTFd/conf/nginx
- Edit the
http.conf
file.
nano http.conf
Here is what our nginx config file looks like:
worker_processes auto;
events {
worker_connections 1024;
}
http {
# Configuration containing list of application servers
upstream ctfd1 {
server ctfd1:8000;
}
upstream ctfd2 {
server ctfd2:8000;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444; # Connection closed without response
}
server {
listen 443 default_server;
listen [::]:443 default_server;
server_name _;
ssl_reject_handshake on; # Reject SSL connection
}
server {
listen 80;
listen [::]:80;
server_name sub1.domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name sub2.domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name sub1.domain.com;
ssl_certificate /certificates/origin.pem;
ssl_certificate_key /certificates/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
gzip on;
client_max_body_size 4G;
# Handle Server Sent Events for Notifications
location /events {
proxy_pass http://ctfd1;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
proxy_redirect off;
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-Host $scheme;
}
# Proxy connections to the application servers
location / {
proxy_pass http://ctfd1;
proxy_redirect off;
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-Host $server_name;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name sub2.domain.com;
ssl_certificate /certificates/origin.pem;
ssl_certificate_key /certificates/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
gzip on;
client_max_body_size 4G;
# Handle Server Sent Events for Notifications
location /events {
proxy_pass http://ctfd2;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
proxy_redirect off;
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-Host $scheme;
}
# Proxy connections to the application servers
location / {
proxy_pass http://ctfd2;
proxy_redirect off;
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-Host $server_name;
}
}
}
This config is setup to deny any incoming requests that are not going to sub1.domain.com
or sub2.domain.com
and forwards all http traffic to https and then to the respective docker application on localhost. You will need to replace sub1.domain.com
and sub2.domain.com
with your respective subdomains. This can be easily done by copying the config into a source-code editor like Visual Studio Code and using the find and replace feature.
In the same directory, we are going to place our cloudflare origin and key files.
- Make sure you are in the correct directory.
cd /etc/docker/CTFd/conf/nginx
- Create a new origin file.
touch origin.pem
- Create a new key file.
touch key.pem
- Copy the contents of your origin cert to the origin.pem file you just created.
echo "content of origin cert goes here in quotes" > origin.pem
- Copy the contents of your key to the key.pem file you just created.
echo "content of key goes here in quotes" > key.pem
FINISHING UP
Now that everything is configured, we can compose the docker application.
- Navigate to the root CTFd directory.
cd /etc/docker/CTFd
- Compose the docker application.
docker compose up -d
Once the application is up, you should now be able to access both of your CTFd applications on each subdomain. Please note that DNS records take some time to propagate. If you are unable to access your sites please wait a few hours and try again. If you have any issues, please join our discord, we are happy to assist. If you found any issues with this guide, please let me know in the comments below and I will get it corrected.
If you need to troubleshoot, you can check the docker compose logs or use SSH tunneling to the localhost port for the CTFd app and then add a custom hosts file (linux | windows) entry to map the FQDN in order to view the app on your system's browser.
Updating CTFd
Updating CTFd is easy with this setup. Just follow the official documentation but perform the git pull inside each CTFd opt directory.
HTML Sanitization
Once CTFd is up, I highly recommend you go into the admin panel and turn on HTML sanitization under the security tab.
Thanks for reading!
- SH3LL