Skip to content

Server Setup

The dashboard is one Go binary and a Postgres database. It serves the web interface, receives uploads from your apps, and holds the socket a live session runs over. There is nothing else to install and no queue or cache to operate.

  • Postgres 16 or later
  • Go 1.25 or later, if building from source
Terminal window
git clone https://github.com/getcodescout/code_scout.git
cd code_scout
docker compose up

That brings up the server and a Postgres instance together. Open http://localhost:24275 and the first account you create becomes the super admin.

Terminal window
git clone https://github.com/getcodescout/code_scout.git
cd code_scout
make dev-setup # writes .env, creates the local Postgres role and database
make run # templ watch + air hot reload on :24275

make build produces a binary at ./bin/code_scout for deployment.

Boot configuration lives in .env for local development, and in the environment or /etc/code-scout.conf for a deployed server. It covers the database connection, the address to listen on and logging: the things that have to be readable before the database is.

Every key has both forms. The environment variable is the TOML key upper-cased with CS_ in front, so db_host is CS_DB_HOST and conn_max_lifetime_minutes is CS_CONN_MAX_LIFETIME_MINUTES. The environment wins wherever both are set.

In the TOML file, numbers go in unquoted. A quoted number is ignored and you silently get the default instead.

KeyEnvironmentDefault
db_hostCS_DB_HOSTrequired
db_nameCS_DB_NAMErequired
db_userCS_DB_USERrequired
db_passwordCS_DB_PASSWORD
db_portCS_DB_PORT5432
db_sslmodeCS_DB_SSLMODEdisablerequire, verify-ca or verify-full. Managed databases such as RDS and Cloud SQL usually need at least require.

The three marked required have no default, and the server refuses to start without them rather than quietly connecting to something you did not mean.

KeyEnvironmentDefault
hostCS_HOST0.0.0.0the bind address. Bare HOST is honoured too, since that is what most platforms inject.
portCS_PORT24275bare PORT likewise.
public_base_urlCS_PUBLIC_BASE_URLthe address the SDK should send logs to, shown in the setup snippet. Empty means “use whatever address you reached the dashboard on”.

public_base_url is deliberately not defaulted to host and port together. host is a bind address, so that would produce http://0.0.0.0:24275, which no phone can reach. Set it when the dashboard and the SDK reach this instance by different names, which is the usual case behind a reverse proxy or on an internal hostname.

KeyEnvironmentDefault
max_open_connsCS_MAX_OPEN_CONNS25keep this under your database’s own connection limit
max_idle_connsCS_MAX_IDLE_CONNS5
conn_max_lifetime_minutesCS_CONN_MAX_LIFETIME_MINUTES30
KeyEnvironmentDefault
log_levelCS_LOG_LEVELinfodebug, info, warn or error
log_formatCS_LOG_FORMATtext or json. Empty picks by destination: text on a console, JSON in a file.
log_fileCS_LOG_FILEempty means stdout
log_max_size_mbCS_LOG_MAX_SIZE_MB100rotate once the file reaches this
log_max_backupsCS_LOG_MAX_BACKUPS7how many rotated files to keep
log_max_age_daysCS_LOG_MAX_AGE_DAYS30
log_compressCS_LOG_COMPRESStruegzip the rotated files

Logs further down covers what these do in practice, and why info rather than debug.

There is no TLS setting. The server speaks plain HTTP and expects a reverse proxy in front of it to terminate TLS, which is covered further down.

Everything else is edited in the dashboard under Settings and stored in Postgres:

SettingEffect
TimezoneEvery timestamp the dashboard renders. Logs stay stored in UTC.
ClockWhether times read as 15:04 or 3:04 PM. Moves every timestamp at once, same as the timezone.
RetentionHow long logs are kept. Applied at the next nightly run.
Upload capLargest single upload. Applied to the very next upload.
Daily log capPer project, per day. 0 means uncapped.

Database credentials deliberately stay out of the UI. A setting that can lock you out of the instance does not belong in the instance.

The schema is migrated on startup, so there is nothing to create by hand. Eleven tables:

TableHolds
projectsName, description, session sampling rate
project_secretsSDK credentials
project_membersWho can see which project, and at what level
project_favoritesPer-user starred projects
usersEmail, name, bcrypt password hash, role
user_sessionsDashboard login sessions, 30 days
personal_access_tokensAPI tokens, stored as a SHA-256 hash and never as the token
instance_settingsThe settings above
project_usage_dailyPer project, per day log counts, for the cap
sessionsOne row per app launch
logsThe logs themselves

Sign in and use Add project. The wizard walks through the name, who can see it, and then shows the project id and secret once, with the snippet to paste into your app.

The credentials can be retrieved later from Project settings → SDK setup, and rotated there if a build leaks one.

The first account created on a fresh instance is the super admin. Everyone after that is created from Settings → Members, with a temporary password shown once to hand over. No email is ever sent, which is what keeps docker compose up self-contained.

Email is the login identifier. There is no email-based password reset; a higher role resets it. If you lose the last super admin password, the binary can reset it directly:

Terminal window
./bin/code_scout reset-password --email=you@yourteam.dev

See Access and Roles for what each role can do.

PortService
24275The server
9089Templ proxy, development only

Most people put CodeScout behind nginx or Caddy for TLS. Two things about it are not ordinary HTTP, and a default proxy config breaks both without any error to tell you so:

  • A WebSocket. The device end of a live session upgrades the connection. A proxy that does not forward the upgrade delivers an ordinary request instead, and no device can pair.
  • Server-Sent Events. The dashboard watches a live session, and the log viewer tails, over SSE. A proxy that buffers responses holds those events back, so the page looks frozen while the server streams perfectly well.

Neither shows up in a log. Everything else on the instance keeps working, which makes this an easy half hour to lose.

# In the http block, or its own file in conf.d/
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
server_name codescout.example.com;
location / {
proxy_pass http://127.0.0.1:24275;
proxy_http_version 1.1;
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-Proto $scheme;
# Live sessions upgrade to a WebSocket here.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# SSE. Without this the dashboard's live tail delivers nothing until a
# buffer happens to fill.
proxy_buffering off;
# A live session can sit quiet for a long time. The 60 second default
# closes an idle stream; the browser reconnects and no events are lost,
# but it is needless churn.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}

Use the map rather than hardcoding proxy_set_header Connection "upgrade". The hardcoded version sends that header on every request, including ordinary page loads, and breaks HTTP keepalive to the server. The map sends it only when a client actually asked to upgrade.

Caddy handles both by itself. reverse_proxy 127.0.0.1:24275 is the whole configuration: WebSocket upgrades pass through, and responses are streamed rather than buffered.

The proxy answers 401 for /api/live/socket whether or not upgrades pass, because credentials are checked first, so curl cannot tell you. The real test is to pair a device: mint a code on the Live screen, type it into the SDK’s floating button, and see the device appear as connected. If it never does, the upgrade is not reaching the server.

/api/mcp, which is how an AI agent reads your logs, is ordinary HTTP POSTs on the same port as everything else. It holds no connection open and needs no proxy configuration beyond what the dashboard already has, so if your pages load, it works. See Reading CodeScout with an AI agent.

GET /healthz reports the version, the commit and the state of the database. It sits outside the request logging middleware so orchestrator probes do not bury real traffic, and it needs no login, so it is the quickest way to ask a running instance what it is.

{"status":"ok","db":"ok","version":"1.1.0","commit":"1fb1e99"}

By default the server writes to stdout, which is where both systemd and Docker expect it and which both already capture and rotate:

Terminal window
journalctl -u code_scout -f # a service
docker compose logs -f code_scout # a container

To write a file instead, set log_file. The server rotates it itself, so do not also point logrotate at it or the two will fight over the same file.

log_level = "info" # debug, info, warn, error
log_file = "/var/log/code_scout/code_scout.log" # empty means stdout
log_max_size_mb = 100 # rotate when it reaches this
log_max_backups = 7 # keep this many rotated files
log_max_age_days = 30
log_compress = true

Every key has a CS_ environment variable, so CS_LOG_LEVEL=debug works without editing the file. make deploy-setup creates /var/log/code_scout and sets log_file for you.

Lines are human readable on a console and JSON in a file, since one is read by a person and the other by a program. log_format forces either.

Errors and fatals always go to stderr as well, even when a file is configured. A server that fails to start writes the reason where you would look for it rather than only into a file.

log_level = "info" is deliberate. At debug every database call is logged, which is useful when you are chasing something and overwhelming when you are not. Requests to /healthz and /static/ are debug regardless, so a health probe every 15 seconds does not bury real traffic.

One line per request, when it finishes:

level=info msg=Request method=GET path=/project/x/logs status=200
duration_ms=14.207 bytes=4829 remote_ip=203.0.113.7 request_id=req-8f2a

request_id is on every line that request produced, including the database ones at debug, so grep req-8f2a gives you the whole story of one request.

Behind a proxy, remote_ip is read from X-Forwarded-For. That header can be set by anyone talking to the server directly, so it is for reading logs and never for a decision about access.

Three places say so, and they all read the same constant:

  • The foot of the sidebar on any project screen, and the top bar elsewhere. Hover it for the commit and the build time.
  • Settings, General, About, which also has the update check.
  • GET /healthz, as above.

Quote the version and the commit when you report a problem. The version alone does not identify a build made between releases.

CodeScout asks GitHub once a day whether a newer release exists, and shows a small badge next to the version when there is one. Only super admins see the badge, since nobody else can act on it.

The request carries nothing about your instance. No project names, no counts, no identifier of any kind. GitHub sees that an address asked a public question, which is less than it learns from someone opening the repository in a browser.

Turn it off in Settings, General, About. With it off the instance makes no outbound requests at all. If it cannot reach GitHub it says so on that card and is otherwise quiet, so an air-gapped instance is not nagged about a failure it cannot fix.

Read the changelog first, then:

Terminal window
# Docker
docker compose pull && docker compose up -d
# From source
git pull && make build && sudo systemctl restart code_scout

There is no migration step. The schema is applied at startup, so a new binary brings the database up to date on its way up.

latest is the newest release, and 1.1.0 and 1.1 pin that release exactly. edge is built from every push to main, which is the right choice for trying an unreleased fix and the wrong one for anything you rely on. Every release also appears on the releases page with its notes and a Linux binary.

Pin a version. latest moving under a container that restarts on its own is how an upgrade happens at a time nobody chose.