Developing on a FiveM server

Running a separate FiveM development server, resource structure, securing server events, and deploying safely to live.

Game servers9 min read

On this page
  1. Run a separate development server
  2. How a resource is put together
  3. Client and server: where the trust line is
  4. Events any client can trigger
  5. Performance mistakes that show up as hitch
  6. Measure instead of guessing
  7. Keep your resources in source control
  8. Deploying to the live server
  9. Related

Write and test resources on a server your players are not on. Everything else on this page follows from that: a development server is what lets you restart resources, break things, and read errors without a hundred people watching.

This page covers setting that server up, how a resource is put together, where the security line between client and server sits, the performance mistakes that show up as hitch, and a deployment routine that has a way back.

Run a separate development server

Testing on live is not a shortcut, it is a bet. Restarting a resource drops the state it was holding for connected players. A script error in a loop hitches everyone. A database change that turns out to be wrong is wrong for real player data. None of that is recoverable at 9pm on a Saturday.

Three setups work, in descending order of safety.

  • A second server. Same operating system, same FXServer build, its own database. The closest match to live, and nothing you do on it can touch your players.
  • A second FXServer instance on the same machine. Its own directory, its own port, its own database schema. Cheaper, but it shares CPU with your live server, so a runaway script competes with real players. Never load-test on this one.
  • FXServer on your own machine. Fine for pure script work, and zero risk to anything. Furthest from what live actually looks like.

Whichever you pick:

  1. Give it its own license key

    Generate a separate key in the Cfx.re keymaster rather than sharing the live server's. A key is meant for one running server.

  2. Keep it off the public server list

    Setting sv_master1 "" in the development server's config stops it advertising itself. People find test servers, join them, and report the bugs you already know about.

  3. Restrict who can reach it

    The firewall inside your server is separate from the protection rules in the panel, and it is the right place to limit a development port to your own address. On Ubuntu or Debian with ufw:

    Shell
    sudo ufw allow from 203.0.113.10 to any port 30125 proto udp
    sudo ufw allow from 203.0.113.10 to any port 30125 proto tcp

    Enabling ufw denies everything you have not allowed. Before you enable it, allow your SSH port, or you lock yourself out of the machine, and on a box that also runs your live server allow TCP and UDP on the live game port, or you disconnect every player on it. Any port you do open to the public still needs a rule in the panel: see Port rules.

  4. Give it its own database

    A separate schema, loaded from a dump of live, never a connection to live.

  5. Make the two consoles impossible to confuse

    A distinct sv_hostname, a small sv_maxclients, a different terminal colour, whatever it takes. Running a command in the wrong window is the classic way this setup fails. See The server.cfg file.

How a resource is put together

Resources live under resources/ in your server data directory. A folder whose name is in square brackets, such as [local] or [jobs], is a category rather than a resource: it groups resources and is not started itself. FXServer discovers what is there, and ensure name in your server.cfg is what actually starts one. Order matters when one resource depends on another.

Every resource needs fxmanifest.lua in its root. That file is the contract between your code and the server:

Lua
fx_version 'cerulean'
game 'gta5'
 
author 'Your name'
description 'What this resource does'
version '1.0.0'
 
shared_script 'config.lua'
 
client_scripts {
    'client/main.lua',
    'client/ui.lua'
}
 
server_scripts {
    'server/main.lua'
}
 
files {
    'html/index.html',
    'html/style.css'
}
 
ui_page 'html/index.html'

Three things about that file catch people out:

  • client_scripts are sent to the player automatically. Everything else is not. An HTML file, an image, or a sound the client needs must be listed under files, or the client will not have it. This is the usual cause of a resource that works for you and not for anyone else.
  • shared_script runs on both sides. It is for configuration and helpers, not for anything you would not hand to a player, because everything in it ships to the client.
  • The manifest is read when the resource list is refreshed. After adding a new resource folder or editing fxmanifest.lua, run refresh in the server console before ensure. After editing script contents only, ensure name on its own is enough.
Your serverstays hereA player's computerthey can read all of itserver_scriptsserver/main.luashared_script: both sidesconfig.luaconfig.luaclient_scriptsclient/main.luaclient/ui.luafileshtml/index.htmlhtml/style.cssnot listed under filesan image the client needsnever sent to the client
Where each line of the example manifest ends up. Only server_scripts stay on your server; client scripts, the shared script and everything under files are copied to every player, who can read them, and a file the client needs that is not listed is simply never sent. Illustration.

The console commands you will use constantly:

refresh
ensure myresource
restart myresource
stop myresource

Client and server: where the trust line is

A client script runs on the player's computer. They can read it, change what it sends, and send things it would never send. Executors exist for exactly that purpose and they are not rare. So:

Treat every client script as published, and every message from a client as something an attacker wrote.

The server is the only place where something becomes true. Money, items, jobs, permissions, and positions of record are decided server side, checked server side, and stored server side. The client asks, the server decides, the server tells the client what happened.

Anything you must not leak stays out of client scripts entirely: API keys, Discord webhook URLs, and the list of admin identifiers all ship to every player if you put them in a client file.

Events any client can trigger

RegisterNetEvent makes an event callable by every connected client, with any arguments, as often as they like. That is its whole purpose, and it is also the most common hole in a FiveM server.

Inside a server handler, source is the player who triggered the event and is set by the server, so it is trustworthy. A player id passed as an argument is not.

Lua
-- Unsafe: the client chooses who gets paid and how much.
RegisterNetEvent('bank:deposit')
AddEventHandler('bank:deposit', function(playerId, amount)
    addBankBalance(playerId, amount)
end)
Lua
-- The server decides. The client only asks.
RegisterNetEvent('bank:deposit')
AddEventHandler('bank:deposit', function(amount)
    local src = source
 
    amount = tonumber(amount)
    if not amount or amount <= 0 or amount ~= math.floor(amount) then return end
    if not isNearBank(src) then return end
    if getCash(src) < amount then return end
 
    takeCash(src, amount)
    addBankBalance(src, amount)
end)

Four habits cover most of it:

  • Capture source into a local on the first line of the handler. It is not preserved across a Wait or an asynchronous database call, and code that reads source after one is reading whatever happens to be there.
  • Validate every argument. Type, sign, whole number, in range, and that the thing it names exists. A number a client sent is a number a client chose.
  • Check permission on the server for anything privileged. ACE principals configured in server.cfg are the built-in way to do it, checked with IsPlayerAceAllowed(src, 'command.something'). A client-side check on whether someone is an admin is decoration.
  • Rate-limit anything expensive. A client can trigger an event in a loop, so a per-player cooldown belongs on anything that writes to the database or makes an HTTP request.
The server handler, in orderA client asksTriggerServerEvent('bank:deposit', amount). Treatit as written by an attacker.the trust line1Capture source into a localSet by the server, so it can betrusted. Read it before any Wait.2Validate every argumentamount: a number, above 0, a wholenumberreturn3Check what the player may doNear a bank, has the cash;IsPlayerAceAllowed for anythingprivilegedreturn4Rate-limit anything expensiveA per-player cooldown on databasewrites and HTTP callsreturnThe server decidesTakes the cash, adds to the balance, and tellsthe client what happened.
The four habits as the handler meets them, using the bank:deposit example. Everything above the trust line was chosen by the client; every check after the first can end the handler with nothing changed, and only a request that passes them all moves money. Illustration.

In the other direction, TriggerClientEvent('event', -1, data) sends to every player. Send to one source when only that player needs it, both for privacy and because broadcasting at high frequency is bandwidth you are choosing to spend.

Performance mistakes that show up as hitch

Most FiveM performance problems are written into resource code, not caused by the machine. Optimising a FiveM server covers diagnosis and the server-wide picture. These are the ones you introduce while writing:

A loop with no wait. A thread that never yields blocks everything behind it.

Lua
-- Hangs the thread. Nothing else in this resource runs.
CreateThread(function()
    while true do
        doSomething()
    end
end)

Wait(0) for work that does not need every frame. Wait(0) runs your loop sixty or more times a second. Use the longest wait that still feels correct, and vary it by distance so the fast path only happens when the player is somewhere it matters:

Lua
CreateThread(function()
    while true do
        local sleep = 1000
        local coords = GetEntityCoords(PlayerPedId())
 
        if #(coords - shopCoords) < 15.0 then
            sleep = 0
            DrawMarker(--[[ ... ]])
        end
 
        Wait(sleep)
    end
end)

Drawing and native calls with no distance gate. Markers, 3D text, and help text drawn for every location on the map, every frame, whether or not the player can see them.

Per-tick loops on the server. Iterating every player every frame to check something that changes rarely. Drive it from an event instead, or run it on a timer measured in seconds.

Database work inside a loop. One query per player per tick will find the limit of your database before it finds the limit of your server. Batch the query, and use your MySQL wrapper's asynchronous form so the main thread does not wait on it.

Errors you have stopped reading. A script erroring every frame is doing that work every frame. A clean console is a performance feature.

Measure instead of guessing

Open the client console with F8 and run resmon. It shows CPU time per resource in milliseconds. Watch your resource while the player is idle and again while they are in the situation it handles: a resource that costs nothing at rest and climbs when a menu opens is telling you exactly where to look.

For a breakdown inside a resource, use the built-in profiler. Recording and viewing are separate commands, and the subcommands vary between builds, so run profiler with no arguments in the console to see what yours supports before relying on a particular one. Profile on the development server, where you can reproduce the problem deliberately.

Keep your resources in source control

The server is not your backup, and "the version that is running" is not a version you can go back to.

  • One git repository per resource is the common shape, and it makes updating a single resource on live a git pull in one directory. A single repository for a set of resources you wrote together is also fine. Pick one and be consistent.
  • Never commit secrets. sv_licenseKey, rcon_password, and your database connection string belong in a separate config file that your main one pulls in with exec, and that file belongs in .gitignore. Anything you have already pushed should be treated as public and changed.
  • Ignore what you can regenerate. Build output, node_modules/, editor directories, and any local cache.
  • Tag or branch what is live. Knowing exactly which commit is running is what makes a rollback a command rather than an investigation.
  • Read the diff before you deploy, including on third-party resources. A "small update" that adds an HTTP call to a host you do not recognise is something you find by reading the diff and by no other means.

Deploying to the live server

  1. Prove it on the development server first

    Including the restart. Some resources hold state for connected players, and restarting them mid-session logs people out of an inventory or a job. If that is true of yours, you need to know before you find out on live.

  2. Dump the database

    Shell
    mysqldump -u fivem -p fivem_live > backup-$(date +%F-%H%M).sql

    The same command works on MariaDB. Keep the dump somewhere that is not the server you are about to change.

  3. Deploy at your quietest hour

    Not during an event, not during your peak. A deploy with twelve people online is a different event from a deploy with a hundred and twenty.

  4. Copy the new version across

    A git pull in the resource directory, or rsync or SFTP from your machine. See Connecting to your server for the address and credentials.

  5. Restart the one resource, not the server

    refresh
    ensure myresource

    refresh first if the resource is new or its manifest changed. Restarting the whole server disconnects everybody, so keep that for changes that genuinely require it.

  6. Watch the console, then watch a client

    Read the console as the resource starts and for a few minutes after. Then join and run resmon on F8 to confirm the new version costs what you expected.

  7. Know your way back before you need it

    Check out the previous tag and ensure the resource again. If the change touched the database, the dump from step two is the way back, which is why it is step two.

Need a server, or a hand with one?

Pick a plan to deploy a server, or open a ticket in the panel. Support runs 24/7/365.