luaguides

Lua as a Configuration Language

Overview

Lua tables are natural data structures for configuration. A Lua config file is just a .lua file that returns a table. You load it 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,
}

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. 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:

-- 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.

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.

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