Lua configuration: using Lua tables for flexible app settings
Overview
Lua tables are natural data structures for configuration, and Lua configuration files are just .lua scripts that return a table. You load them with loadfile or dofile, and you have a structured configuration object with all the expressiveness of Lua behind it.
The advantage over JSON or YAML is that Lua configs can contain computed values, environment checks, conditional logic, and functions. You can write host = os.getenv("DB_HOST") or "localhost" — something impossible in a static config format. This makes Lua a favourite for tools like OpenResty, Neovim, and custom applications that need flexible configuration without a separate config language parser.
Basic config files
Returning a table
A config file is a Lua file that ends with a return statement:
-- config.lua
return {
host = "localhost",
port = 5432,
debug = false,
max_connections = 100,
}
The returned table is a plain Lua value with no special wrapping — any key you define becomes available to the application that loads the file. This means you can nest subtables, include numeric values, and even embed functions that compute derived settings on the fly.
Loading the config
local load_config = loadfile("config.lua")
local config = load_config()
print(config.host) -- "localhost"
print(config.port) -- 5432
loadfile returns a function if the file compiles successfully, or nil plus an error message. This two-step approach gives you control: you can check for syntax problems, validate the returned table, or wrap the call in pcall before using the result. dofile is simpler but throws an error directly if the file has a syntax problem:
-- Using dofile (throws on syntax error)
local config = dofile("config.lua")
-- Using loadfile (gives you control over errors)
local fn, err = loadfile("config.lua")
if not fn then
error("Config failed to load: " .. err)
end
local config = fn()
Environment-Based Configuration
One of the most useful patterns is varying config by environment without duplicating the whole file. Instead of maintaining separate config files for development, staging, and production, you write a single file that inspects an environment variable and adjusts values accordingly:
-- config.lua
local env = os.getenv("APP_ENV") or "development"
local defaults = {
host = "localhost",
port = 5432,
debug = true,
pool_size = 10,
}
if env == "production" then
defaults.host = "db.example.com"
defaults.port = 5433
defaults.debug = false
defaults.pool_size = 50
end
return defaults
The calling code never changes — it just loads config.lua and reads values. The config file handles the environment branching internally. This isolation is key: your application logic stays clean, and all the conditional configuration lives in one place where it is easy to audit and update without touching the main codebase.
Sharing values across config sections
Config files can also define helpers that the application uses:
-- config.lua
local function read_env(key, default)
local val = os.getenv(key)
return val ~= "" and val or default
end
return {
database = {
host = read_env("DB_HOST", "localhost"),
port = tonumber(read_env("DB_PORT", "5432")),
},
cache = {
enabled = os.getenv("CACHE_DISABLED") ~= "true",
ttl = 300,
},
}
Since the config file is just Lua, you can define local functions and use them to build up the returned table. The application receives a flat table with no dependency on the helper functions.
Validating config with metatables
One risk of Lua config files is that typos in keys produce silent failures. You can guard against this with a metatable that warns when an unexpected key is accessed:
-- config_with_validation.lua
local known_keys = {
host = true, port = true, debug = true,
max_connections = true, pool_size = true,
}
local config_mt = {
__index = function(self, key)
if not known_keys[key] then
io.stderr:write("Warning: unknown config key '" .. tostring(key) .. "'\n")
end
return rawget(self, key)
end,
}
local config = {
host = "localhost",
port = 5432,
debug = false,
max_connections = 100,
}
setmetatable(config, config_mt)
return config
Now accessing config.max_connections (correct) works normally, but config.max_connection (typo) triggers a warning. This is especially useful in larger config files where it is easy to mistype a key.
Table-driven config for multiple environments
For projects with many environment-specific values, a single config file with a lookup table keeps things organised:
-- config.lua
local env = os.getenv("APP_ENV") or "development"
local configs = {
development = {
host = "localhost",
port = 5432,
log_level = "debug",
},
staging = {
host = "staging-db.example.com",
port = 5432,
log_level = "info",
},
production = {
host = "prod-db.example.com",
port = 5433,
log_level = "warn",
},
}
local env_config = configs[env]
if not env_config then
error("Unknown environment: " .. env)
end
return env_config
The calling code stays the same regardless of which environment is active; just set APP_ENV before loading. This lookup-table approach scales well to a handful of environments and keeps all your per-environment overrides visible in one place, which makes it easy to spot differences between staging and production at a glance.
Loading config from non-standard locations
loadfile accepts an absolute path, so you can load config from arbitrary locations:
local function load_config(path)
local fn, err = loadfile(path)
if not fn then
error("Cannot load config from " .. path .. ": " .. err)
end
return fn()
end
-- Allow override via environment variable
local config_path = os.getenv("CONFIG_PATH") or "./config.lua"
local config = load_config(config_path)
This pattern is common in CLI tools where the user specifies --config /path/to/config.lua.
Security: sandboxed config loading
By default, loadfile runs in the global environment. If your config files come from an untrusted source, use a sandbox to limit what they can do:
local function safe_load_config(path)
local fn, err = loadfile(path)
if not fn then
error("Cannot load config: " .. err)
end
-- Restrict the environment: only allow safe globals
local safe_env = {
math = math,
string = string,
table = table,
ipairs = ipairs,
pairs = pairs,
tonumber = tonumber,
tostring = tostring,
os = {
.getenv = os.getenv,
},
rawget = rawget,
rawset = rawset,
setmetatable = setmetatable,
getmetatable = getmetatable,
}
setfenv(fn, safe_env)
return fn()
end
The config file can still compute values and use environment variables, but it cannot access the filesystem, network, or other sensitive parts of the host.
Common Mistakes
Using dofile in production without error handling. dofile throws directly on syntax errors. Wrap with loadfile and check the returned function before calling it.
Returning nil accidentally. If the last line of the config file does not have a return statement, loadfile returns a function that produces nil. Always end the config file with return { ... }.
Mutating the returned table. If multiple parts of your code modify config directly, changes in one place affect all others. Treat the config as read-only, or copy it with table.move or a shallow copy if you need to modify it per-request.
Typos in key names. Without validation, config.max_connectionS silently returns nil. Add the metatable validation pattern above if your config files are large.
See Also
- /guides/lua-serialization/ — serialising and deserialising Lua data structures
- /guides/lua-environments/ — Lua environments and how
setfenvworks (used in sandboxing) - /guides/lua-metatables/ — metatables for validation and custom access patterns