[Blog](https://deploywithox.com/blog) [Guides](https://deploywithox.com/blog#c-guides) # How to self-host CTFd on your own VPS, without Docker Run CTFd 3.8.8, the Capture The Flag platform, on your own Ubuntu server with ox: gunicorn, MariaDB and Redis, from the config that passed its test. Saurav Sharma Oct 17, 2026 9 min read On this page - [What you need](https://deploywithox.com/blog/self-host-ctfd#what-you-need) - [The steps](https://deploywithox.com/blog/self-host-ctfd#steps) - [The ox.toml, line by line](https://deploywithox.com/blog/self-host-ctfd#config) - [MariaDB and Redis](https://deploywithox.com/blog/self-host-ctfd#services) - [Updates and backups](https://deploywithox.com/blog/self-host-ctfd#updates) - [Frequently asked questions](https://deploywithox.com/blog/self-host-ctfd#faq) To self-host CTFd you need an Ubuntu server, a fork of the CTFd repo with one `ox.toml` file added, and ten variables, of which you choose only four. In our run the first deploy took about 2 minutes, and ox set up MariaDB and Redis for it. **How we tested** On Oct 17, 2026, ox deployed CTFd 3.8.8 from commit [`b4a2b319`](https://github.com/CTFd/CTFd/commit/b4a2b31946ab8c814651e09579eca55d3096dcb7) on a fresh DigitalOcean server with 8 vCPUs and 16 GB of memory. The run then redeployed it, backed it up and rebooted the server. Then a separate test agent, one that did not write the config, signed in, created a challenge, had a new player solve it, and saw the player's points on the scoreboard after a reload, and checked the app's services and its backup. [CTFd](https://github.com/CTFd/CTFd) runs Capture The Flag contests, the security games where players solve puzzles to find a secret string called a flag. The organizer writes challenges and flags in an Admin Panel, and players earn points on a live scoreboard. The license is Apache 2.0. CTFd is a Flask app, served by gunicorn with gevent workers. It keeps its data in MariaDB or MySQL and uses Redis as a cache. ox deployed CTFd 3.8.8 (commit b4a2b319) on Oct 17, 2026 on a fresh DigitalOcean server with 8 vCPUs and 16 GB of memory; the first deploy took 2 minutes and a redeploy took 39 seconds. - **First deploy:** 2 minutes, from adding the project until the app passed the run's first checks. - **Redeploy of the same commit:** 39 seconds, until the app passed the checks again. - **Server:** DigitalOcean, 8 vCPUs and 16 GB of memory (`s-8vcpu-16gb`), with this app alone on it. Every one of the 85 apps we deployed ran on this one size, so it was not picked for this app. - **Memory in use after the reboot:** 1028 MB of 15986 MB, for the whole server: Ubuntu, the app and its staging copy, its services, Caddy and the ox agent. On a server with no apps, ox's tests on real servers hold the agent to 40 MB or less and Caddy to 50 MB or less. - **Services ox runs for it:** `mysql`, `redis`. - **Source:** [github.com/CTFd/CTFd](https://github.com/CTFd/CTFd) at 3.8.8, commit [`b4a2b319`](https://github.com/CTFd/CTFd/commit/b4a2b31946ab8c814651e09579eca55d3096dcb7). The CTFd home page of the test event ox CTF The CTFd admin panel showing the new 100-point challenge Warm up as visible A player's flag submission for Warm up marked Correct in CTFd ## What you need - An Ubuntu LTS server that you can reach as root. The run used the size named in the facts above. - A GitHub account to fork `CTFd/CTFd`, and an ox account, which you open by signing in with GitHub. - A name, an email address and a password for the admin, and a long random string for `SECRET_KEY`. - A mail server, only if you want CTFd to send email. The run set none up, so email, including a password reset, was not tested. ## The steps In the ox dashboard, press Add a server and run the install command it shows, as root, on your server. Only the ox agent and Caddy go onto the host. Fork `CTFd/CTFd` on GitHub and name the fork `ctfd`, so the project gets that name. Check out commit `b4a2b319`, the 3.8.8 code the run deployed, add the `ox.toml` shown below at the root, then commit and push. No other file was added. Next write `ctfd.env` on your laptop with the ten keys listed under the variables section. With the [ox CLI](https://deploywithox.com/docs/cli) signed in, run: Shell ```bash ox new /ctfd ox review ctfd --from-file ctfd.env --wait ``` The review saves your values and starts the first deploy, and `--wait` prints the log until it ends. The [quickstart](https://deploywithox.com/docs/quickstart) shows both steps in the browser. When the deploy is green, open the project's address. A new CTFd sends every page to `/setup`, its first-run wizard. The run finished the wizard by submitting its form with the event name ox CTF, User Mode, the Core theme, and the same name, email and password it had saved as `PRESET_ADMIN_NAME`, `PRESET_ADMIN_EMAIL` and `PRESET_ADMIN_PASSWORD`. The wizard's own pages were not clicked through. On its Administration tab a box, ticked by default, sends the admin's email to CTFd's newsletter list when you press Finish. The run did not use it. Sign in at `/login` with that name or email and the password. The test agent also tried a wrong password and saw "Your username or password is incorrect". To see CTFd work, repeat the test agent's steps. In the Admin Panel open Challenges, press the plus beside the heading, fill in Name, Category, Message and Value, and press Create. In the Options window type a flag, set State to Visible and press Finish. Then register a player at `/register` in a private window, open Challenges, click the card, type the flag and press Submit. CTFd says Correct, and after a reload `/scoreboard` lists the player with the points. The wizard's admin is hidden from the scoreboard, so test a solve as a player. The run used the preview address ox gives each project. It did not try a domain of your own, and the [domains page](https://deploywithox.com/docs/operate/domains) explains how ox adds one. ## The ox.toml, line by line TOML ```toml # CTFd 3.8.8 (Flask, MariaDB, Redis), run the way its Dockerfile and # docker-entrypoint.sh run it, without Docker. # With no SECRET_KEY, CTFd writes .ctfd_secret_key into the release, which # is read-only, so each worker makes a key of its own and every restart # signs everyone out (CTFd/config.py, gen_secret_key). required_variables = ["SECRET_KEY"] [app] # The entrypoint's server on ox's port: gunicorn on the app factory with # gevent workers, logs to the journal. One worker, the entrypoint's and # docker-compose.yml's WORKERS. start = "exec gunicorn 'CTFd:create_app()' --bind 127.0.0.1:$PORT --workers 1 --worker-class gevent --access-logfile - --error-logfile -" # OK only when the database exists and the cache answers (views.healthcheck). health = "/healthcheck" [build] # The entrypoint's own step before it starts the server. migrate = "flask db upgrade" [storage] # CTFd's default UPLOAD_FOLDER and LOG_FOLDER (CTFd/config.py): the folders # docker-compose.yml mounts as volumes. keep = ["CTFd/uploads", "CTFd/logs"] [services] # docker-compose.yml's db (MariaDB) and cache (Redis). CTFd reads REDIS_URL # as ox provides it; the database comes from the DATABASE_* variables. mysql = {} redis = {} ``` The header comment says the file runs CTFd the way its `Dockerfile` and `docker-entrypoint.sh` run it, minus Docker. - `required_variables` asks for `SECRET_KEY` before the first deploy. With no key, CTFd tries to save a `.ctfd_secret_key` file in its own folder. ox runs it from a read-only release, so each worker would make a random key of its own and every restart would sign everyone out. - `start` is the entrypoint's server: gunicorn on the app factory `CTFd:create_app()`, with gevent workers and logs sent to the journal. It binds `127.0.0.1:$PORT`, the port ox hands the process, with Caddy in front, where the entrypoint binds `0.0.0.0:8000`. One worker is the default in the entrypoint and in `docker-compose.yml`. - `health` is `/healthcheck`. CTFd answers OK there only when its database exists and its cache takes a write and returns it. ox switches to a new release once it does. - `migrate` is `flask db upgrade`, the entrypoint's own step before it starts the server. - `[storage] keep` lists `CTFd/uploads` and `CTFd/logs`, CTFd's default folders for uploaded files and for its logs of registrations, flag submissions and logins. The compose file mounts both as volumes, and ox keeps them across releases. - `[services]` asks for `mysql` and `redis`, which stand where the compose file has its `db` and `cache`. ox's mysql is MariaDB, like the compose file's. There is no `[tools]` section and no `install` line. CTFd's Dockerfile builds on Python 3.11 and ox takes that version, which matters because `config.py` imports `distutils`, a module Python 3.12 removed. For a `requirements.txt`, ox installs the packages with uv on its own. CTFd runs no separate workers or scheduled jobs, so the file declares none. Shell ```bash SECRET_KEY DATABASE_HOST DATABASE_PORT DATABASE_USER DATABASE_PASSWORD DATABASE_NAME REVERSE_PROXY PRESET_ADMIN_NAME PRESET_ADMIN_EMAIL PRESET_ADMIN_PASSWORD ``` `SECRET_KEY` creates sessions and signs strings, and `config.ini` says to set it to a random string. The run used a generated one. The five `DATABASE_` keys are references to the MariaDB service: `DATABASE_HOST` is `${mysql.host}`, and the port, user, password and database take `${mysql.port}`, `${mysql.user}`, `${mysql.password}` and `${mysql.database}`. CTFd builds its `mysql+pymysql` connection URL from the five parts. `REVERSE_PROXY` is `true`, the value the compose file uses. `config.ini` says to set it behind a proxy such as nginx, so CTFd trusts the proxy's forwarded address, scheme, host and port headers. Caddy is that proxy here. See [variables](https://deploywithox.com/docs/operate/variables) for how references work. The three `PRESET_ADMIN_` keys are CTFd's own preset admin settings. On the login form CTFd compares what you type with them before it looks at stored passwords, so the password in the variable signs in as that admin for as long as the variable is set. ## MariaDB and Redis Both services are private to the project, each its own systemd unit with its own data and password. The run also deployed a staging copy beside production, with its own pair, and the memory figure above counts both. CTFd uses Redis for its cache and for the live notifications it pushes to players' browsers. By default it also asks `versioning.ctfd.io` whether a newer release exists, and the request carries anonymous usage numbers: a hash of the event name, the CTFd and Python versions, the database type, and counts of challenges, users and teams, plus the user mode, theme and upload provider. The code says setting `UPDATE_CHECK` to false turns it off. The run left it on. ## Updates and backups The run redeployed the same commit. ox built a new release beside the running one, ran `flask db upgrade` and switched when `/healthcheck` answered. A deploy takes whatever commit your fork's branch holds, from a push or from `ox deploy ctfd --wait`. A move to a newer CTFd release was not tested. The run's backup, made with Back up now, held a MariaDB archive and one archive for each kept folder, and the same backups run daily. Run `ox backups ctfd` to list them. A restore was not tested. The [backups page](https://deploywithox.com/docs/operate/backups) covers keeping copies in an S3 bucket. ## Frequently asked questions ### Do I need Docker to self-host CTFd? No. CTFd ships a Dockerfile and a compose file, but the app is an ordinary Python program. ox installs its requirements, runs gunicorn as a system service and sets up MariaDB and Redis beside it. The config copies the entrypoint's two steps, the database upgrade and the gunicorn start, so no image is pulled. ### Which database does CTFd need? With no database set, CTFd writes a SQLite file. Given a host, it uses MariaDB or MySQL, and its compose file runs MariaDB with Redis as the cache. The run used MariaDB, which ox set up from one line of ox.toml. CTFd builds the connection URL from five DATABASE_ variables, so you paste no URL. ### How do I create the first CTFd admin? A new CTFd sends every page to /setup, a wizard whose Administration tab makes the first admin. The run submitted that form with the same name, email and password it had saved as the three PRESET_ADMIN_ variables, then signed in at /login. Players make their own accounts at /register, which is open by default. ### How much memory does CTFd use? On the run's server the whole machine used the memory shown in the facts after the reboot. That counts CTFd and its staging copy, two MariaDB and two Redis instances, Caddy and the ox agent. ox takes any server from 1 GB up, and says so when memory is why an app stopped. New to running apps on your own server? [How ox self-hosts your apps](https://deploywithox.com/self-hosting) shows what ox sets up on your server. On this page - [What you need](https://deploywithox.com/blog/self-host-ctfd#what-you-need) - [The steps](https://deploywithox.com/blog/self-host-ctfd#steps) - [The ox.toml, line by line](https://deploywithox.com/blog/self-host-ctfd#config) - [MariaDB and Redis](https://deploywithox.com/blog/self-host-ctfd#services) - [Updates and backups](https://deploywithox.com/blog/self-host-ctfd#updates) - [Frequently asked questions](https://deploywithox.com/blog/self-host-ctfd#faq) - [Do I need Docker to self-host CTFd?](https://deploywithox.com/blog/self-host-ctfd#faq-docker) - [Which database does CTFd need?](https://deploywithox.com/blog/self-host-ctfd#faq-database) - [How do I create the first CTFd admin?](https://deploywithox.com/blog/self-host-ctfd#faq-admin) - [How much memory does CTFd use?](https://deploywithox.com/blog/self-host-ctfd#faq-memory) Written by Saurav Sharma Builds ox. Writes about running apps on servers you own, without Docker and without a platform bill. [Older post How to self-host diaspora* without Docker on your own VPS](https://deploywithox.com/blog/self-host-diaspora) [Newer post How to self-host Chatwoot without Docker on your own VPS](https://deploywithox.com/blog/self-host-chatwoot) ## Keep reading - Guides [Self-Hosted Open-Source Apps Deployed with ox: All 85](https://deploywithox.com/blog/self-hosted-open-source-apps-deployed-with-ox) ox deployed 85 open-source apps from pinned commits on fresh servers, with no Docker. The stacks, services, deploy times and a table of every app. Saurav Sharma Oct 17, 2026 16 min read - Guides [How to self-host AFFiNE on your own VPS without Docker](https://deploywithox.com/blog/self-host-affine) Run AFFiNE v0.27.4 on your own Ubuntu server with ox: Postgres with pgvector, Redis, a Rust and Node build, and an admin made from two variables. Saurav Sharma Oct 17, 2026 12 min read - Guides [How to self-host Akaunting on your own VPS without Docker](https://deploywithox.com/blog/self-host-akaunting) Run Akaunting 3.2.4 on your own Ubuntu server with ox: PHP, MariaDB, a scheduler and the first company and admin made on deploy, from the config that ran. Saurav Sharma Oct 17, 2026 10 min read ## Deploy to a server you own ox sets up systemd, Caddy and PostgreSQL on your Ubuntu server and deploys on every push. Free for your first project. [Sign up with GitHub](https://deploywithox.com/login/github) [Read the docs](https://deploywithox.com/docs)