Get your app live in minutes
Neo deploys your Docker app to any VPS over SSH. No agent to install, no platform to sign up for, no infrastructure to manage. If you have a Dockerfile and a server, you're ready.
# 1. Point Neo at your server (installs Docker + Caddy automatically) neo init root@your-ip-address # 2. Deploy your project neo deploy . --domain app.example.com # 3. Get instant HTTPS (no DNS setup needed) neo domain my-app --temp # → https://my-app.your-ip-address.sslip.io
.neo.yml for your project
Run neo config init to scaffold a commented .neo.yml — it prompts for name, domain, and port (auto-detected from your Dockerfile EXPOSE) and stubs the rest. Already using docker-compose? Run neo config generate instead to auto-detect your app, databases, workers, env vars, and volumes.
ssh-agent, then ~/.ssh/id_ed25519 / ~/.ssh/id_rsa. For a cloud .pem or a key at a non-standard path, pass it explicitly with -i (or --key):
neo init [email protected] -i ~/keys/server.pem
ssh-add ~/keys/server.pem first, then neo init without -i. With no key available, Neo prompts for a password.
neo activate up front. One key works on unlimited servers and devices.
What just happened?
Behind those 3 commands, Neo:
.neo.yml file. Keep reading for the full reference.
Installation
One command. Supports macOS (Apple Silicon & Intel), Linux (x86_64 & ARM64), and Windows.
Common Workflows
Real examples for the most common tasks.
Deploy a project with a Dockerfile
cd my-project neo deploy . --domain app.example.com
Migrate from docker-compose
# Auto-generate .neo.yml from your existing docker-compose.yml neo config generate # Deploy everything (app + workers + sidecars) neo deploy . --to production
Local development
# Run locally (wraps docker compose with Neo env loading) neo dev # Stop neo dev down
Day-to-day operations
neo logs my-app # stream logs neo env set my-app API_KEY=sk-123 # set env var (auto-restarts) neo domain my-app blog.example.com # set real domain neo sync my-app # sync changes back to .neo.yml
docker-compose.yml and .env files. If your project already has these, neo deploy . will extract env vars, ports, and service config automatically.
App Templates
Neo ships ten ready-to-run application templates. neo install turns one into a
project folder on your machine — it does not install anything on a server.
You get an ordinary project you can read, edit and commit, and then deploy like any other.
neo install ghost # or just `neo install` for a picker # → asks which folder (default ./ghost) cd ghost neo deploy # this is the step that reaches your server
What gets written
| File | Mode | Contents |
|---|---|---|
docker-compose.yml | 0644 | The app image plus any bundled database, with ports and volumes wired up |
.neo.yml | 0644 | Deploy config: name, domain, port |
.env | 0600 | Generated secrets — database passwords, app keys |
docker-compose.yml and .neo.yml;
keep .env out of git — it is written owner-only for that reason. Neo refuses to
overwrite an existing docker-compose.yml or .neo.yml, so pick an
empty folder.
Available templates
| Template | What it is | Port | Bundled services |
|---|---|---|---|
ghost | Publishing platform / newsletter | 2368 | MySQL |
wordpress | WordPress | 80 | MySQL |
gitea | Self-hosted Git | 3000 | PostgreSQL |
plausible | Privacy-friendly analytics | 8000 | PostgreSQL + ClickHouse |
umami | Web analytics | 3000 | PostgreSQL |
chatwoot | Customer engagement | 3000 | PostgreSQL + Redis |
n8n | Workflow automation | 5678 | PostgreSQL |
miniflux | Minimalist RSS reader | 8080 | PostgreSQL |
vaultwarden | Bitwarden-compatible password manager | 80 | — |
uptime-kuma | Uptime monitoring | 3001 | — |
Every image is pinned to an explicit version rather than :latest, so a redeploy
can't quietly move you to a new major release. Upgrading is a deliberate edit to the tag in
docker-compose.yml.
Project Config (.neo.yml)
The .neo.yml file declares your deployment configuration. Neo can deploy
without one — it will infer the port from your Dockerfile, take the app name
from the directory, and prompt for a domain — but that configuration then exists only in
/etc/neo/state.json on the server. It isn't in your repo, so it can't be
reviewed in a pull request, diffed against last week, or restored if the server is lost.
Commit a .neo.yml. Run neo config init to
scaffold one, or neo config generate to convert an existing
docker-compose.yml. Every individual field is optional; the file itself is
what makes a deploy reproducible.
environments (staging vs production), workers,
release commands, env_encrypted, basic_auth,
volumes, scale, health — none of these are
reachable without a .neo.yml. Zero-config deploys exist for the first
five minutes, not for the second deploy.
By default Neo builds from a Dockerfile in your project root. If yours lives elsewhere (e.g. Dockerfile.local or docker/Dockerfile), set the dockerfile: field or pass --dockerfile / -f to neo deploy. The build context is always the project root.
name: my-app domain: app.example.com port: 8080 https: true basic_auth: user: admin password: s3cr3t bypass: - /api/* - /webhooks/* ssl: certificate: certs/fullchain.pem private_key: certs/privkey.pem dockerfile: Dockerfile env: APP_ENV: production LOG_CHANNEL: stderr env_file: .env.production volumes: data: /var/www/html/database # flat string → named Docker volume storage: /var/www/html/storage workers: queue: command: php artisan queue:work --tries=3 scheduler: command: php artisan schedule:work sidecars: redis: image: redis:7-alpine volumes: cache: /data builder: build: context: ../builder dockerfile: builder/Dockerfile volumes: output: /output dev: env_file: .env # auto-load local .env env: APP_ENV: local APP_DEBUG: "true" volumes: data: ./local-data # override bind-mount path hooks: pre_build: npm run build post_deploy: echo "Deployed!" environments: production: domain: app.example.com env_file: .env.production staging: domain: staging.example.com env: APP_DEBUG: "true"
neo config generate is best-effort and lossy for big multi-service compose files — always review the result. It deploys one app (the build: service) plus internal sidecars, so extra dev services (SMTP, SSO, mocks, object storage) become port-less sidecars. Bind mounts (./data:/…) are dropped, only the first env_file is kept, a sidecar's ports/healthcheck/entrypoint are not migrated, and the build context is always the project root. The command prints a warning for each thing it drops.
name & domain
| Key | Type | Default | Description |
|---|---|---|---|
name | string | Directory name | App name used for container naming (app-{name}) |
domain | string | Prompt | Domain name for Caddy routing |
port | integer | Dockerfile EXPOSE | Port your app listens on inside the container |
dockerfile | string | Dockerfile | Dockerfile path relative to the project root (context stays the root). --dockerfile overrides. |
server | string | Current server | Pin deployment to a specific server by name |
name: my-app domain: app.example.com port: 3000 server: production
env & env_file
Environment variables for your app container. These are merged with other sources during deploy.
| Key | Type | Description |
|---|---|---|
env | map | Key-value pairs set as container environment variables |
env_file | string | Path to a .env file (relative to .neo.yml) |
env_encrypted | string | Path to an encrypted env file — see env_encrypted |
env: APP_ENV: production LOG_CHANNEL: stderr CACHE_DRIVER: redis env_file: .env.production # secrets go here (not committed)
.neo.yml. Use env_file pointing to a .env file that is gitignored, or set them via neo env set.
${VAR} interpolation
Values in .neo.yml may reference other variables as ${VAR} or ${VAR:-default}. On deploy they are resolved from the merged env (env block, env_file, --env) then the OS environment; an unset or empty value uses the :-default when given, otherwise the reference is left as-is. This applies to the env map and to basic_auth, so secrets can live in a gitignored env_file.
release New
Commands that run inside the new container, on the server, after it passes its health check but before Caddy switches traffic to it. This is where database migrations and cache warming belong.
release: - php artisan migrate --force - php artisan config:cache environments: dev: release: # replaces the top-level list - php artisan storage:link - php artisan migrate --force
migrate --force safe to automate.
| Situation | Behaviour |
|---|---|
Scaled app (scale: 3) | Runs once, in the first new replica — migrations must not run concurrently |
--env-only | Runs against the live container (there is no staging container on that path), so a failure is reported, not rolled back |
--all | Runs per environment before that environment's traffic switch; one environment failing doesn't affect the others |
workers: or in the image's
own CMD — putting it here stalls every deploy.
Not to be confused with hooks, which run on your machine (npm build, Slack notifications) and cannot see the deployed container.
dockerfile
Point Neo at a Dockerfile that isn't at the project root — a
docker/ folder, a per-environment build file — instead of
passing --dockerfile on every deploy.
dockerfile: ./docker/Dockerfile # used by every environment environments: dev: dockerfile: ./docker/Dockerfile.dev # overrides the top-level one production: # no dockerfile: here — inherits ./docker/Dockerfile server: prod-box
Resolution order, highest first:
| Order | Source |
|---|---|
| 1 | --dockerfile flag |
| 2 | environments.<env>.dockerfile |
| 3 | top-level dockerfile: |
| 4 | ./Dockerfile |
Paths are relative to the project root (where .neo.yml lives), and the
build context is always the project root regardless of where the
Dockerfile sits — so COPY paths stay relative to the project, not to
the docker/ folder.
--all needs one Dockerfile
neo deploy --all builds a single image and ships it to every
environment. If two environments name different Dockerfiles, Neo stops and
lists the conflict rather than shipping one build as if it were both. Deploy
them individually with neo deploy --to <environment>, or pass
--dockerfile to force one for the whole run.
env_encrypted New
Built for Laravel's encrypted
environment files. Run php artisan env:encrypt, commit the
.env.encrypted it produces, and point env_encrypted: at it — neo
decrypts at deploy time, so secrets live in git while the key lives in your password manager.
neo env encrypt / neo env decrypt are there for when artisan isn't
handy (CI, a machine without PHP); they read and write the exact same format.
# In your Laravel project — key is printed once, save it php artisan env:encrypt # No PHP on this machine? Same file, same format neo env encrypt # Read it back locally php artisan env:decrypt neo env decrypt --stdout
env_encrypted: .env.encrypted # committed; decrypted at deploy environments: production: env_encrypted: .env.production.encrypted staging: env_encrypted: .env.staging.encrypted
If .env.encrypted exists and you have no env_file and no plaintext
.env in the project, neo picks it up automatically — no config needed.
Where the key comes from
On deploy, neo looks for the key in this order and stops at the first hit:
| Order | Source | Use for |
|---|---|---|
| 1 | --env-key flag | One-off deploys |
| 2 | NEO_ENV_KEY | CI |
| 3 | LARAVEL_ENV_ENCRYPTION_KEY | CI that already sets it for Laravel |
| 4 | ~/.neo/keys.json | Your machine — saved after the first prompt |
| 5 | Interactive prompt | First deploy; offers to save the key |
# Save the key once — later deploys never prompt neo env key set my-app base64:xxxxx neo env key list neo env key forget my-app # CI NEO_ENV_KEY=base64:xxxxx neo deploy --to production
/etc/neo/state.json (root-only) so redeploys keep them. Saved keys sit in
~/.neo/keys.json in plain text, mode 0600 — treat that file like an
SSH key. Lose the key and the file cannot be recovered; there is no backdoor.
neo env encrypt writes AES-CBC with an HMAC-SHA256 MAC, matching
env:encrypt. A wrong key or a tampered file fails the MAC check and aborts the
deploy rather than shipping partial config.
volumes
Persistent Docker volumes that survive redeploys. Each volume is created as {app-name}-{volume-name}.
volumes: data: /var/www/html/database # flat string → named Docker volume storage: /var/www/html/storage logs: /var/log/myapp:/var/log/app # host:container → bind mount on server uploads: # structured form (optional) path: /app/uploads mount: /mnt/ssd/uploads # pin to host path on server
| Format | Example | Description |
|---|---|---|
| Flat string | data: /app/data | Named Docker volume mounted at container path |
| Bind mount | logs: /host:/container | Host directory mounted into container |
| Structured | path: + mount: | Named volume with optional host path override |
Volumes declared here are also available to workers (automatically shared) and sidecars (via name reference). During neo dev, volumes are auto bind-mounted to ./{volume-name} in your project directory.
workers
Background worker containers that use the same image, env vars, and volumes as the main app — but run a different command.
workers: queue: command: php artisan queue:work --tries=3 --max-time=3600 scheduler: command: php artisan schedule:work websocket: command: php artisan reverb:start
| Key | Type | Description |
|---|---|---|
command | string Required | Command to run instead of the default CMD |
Workers are deployed with blue-green rollout alongside the main app. View logs with:
neo logs my-app --worker queue
Workers removed from .neo.yml are automatically deleted on the next deploy.
sidecars
Sidecar containers run alongside your app on the same Docker network but have their own image. They are not exposed publicly via Caddy.
Pre-built image
sidecars: redis: image: redis:7-alpine volumes: cache: /data env: MAXMEMORY: 256mb
Built from Dockerfile
sidecars: builder: build: ../builder # string form: path to build context custom: build: # object form: context + dockerfile context: .. dockerfile: docker/custom/Dockerfile volumes: output: /output # shares app volume named "output" env: WORKER_MODE: "true"
| Key | Type | Description |
|---|---|---|
image | string | Pre-built Docker image to pull |
build | string or object | Build context path, or {context, dockerfile} |
volumes | map | Volume name → container path. Names matching app volumes are shared automatically |
env | map | Sidecar-specific environment variables |
command | string | Override the container's CMD |
output), they share the same Docker volume. This is how sidecars like build services can write files that the main app serves.
ssl
Bring your own SSL certificate. The cert and key are uploaded to the server and loaded into Caddy on deploy.
ssl: certificate: certs/fullchain.pem private_key: certs/privkey.pem
| Key | Type | Description |
|---|---|---|
certificate | string Required | Path to PEM certificate file (relative to .neo.yml) |
private_key | string Required | Path to PEM private key file (relative to .neo.yml) |
Can also be set per-environment (see environments) or via CLI:
neo domain my-app app.example.com --cert fullchain.pem --key privkey.pem
https New
Control whether Caddy serves your app over HTTPS with auto-provisioned certificates.
https: true # Caddy auto-provisions SSL via Let's Encrypt
| Value | Behavior |
|---|---|
true | HTTPS with auto-SSL (Let's Encrypt). Requires domain DNS to be pointed at the server. |
false | HTTP only. Use when behind a CDN that handles SSL itself. |
| Not set (default) | First deploy uses HTTP. Enable HTTPS later with neo domain --temp or this config. |
edge_https — behind Cloudflare Flexible SSL
When a CDN terminates TLS at its edge and talks HTTP to your origin (Cloudflare's Flexible SSL mode), set edge_https: true. The origin serves plain HTTP, but Neo injects forwarded headers so your app still generates correct https:// URLs.
edge_https: true # HTTP origin, but app sees X-Forwarded-Proto: https
Neo forwards X-Forwarded-Proto: https, X-Forwarded-Ssl: on, and X-Forwarded-Port: 443 to the container. The CLI equivalent is neo domain my-app --cloudflare-flexible. See Cloudflare Flexible SSL for the full setup.
basic_auth New
Protect your app with HTTP Basic Authentication at the proxy layer. Caddy handles the challenge — the app container is never reached for unauthenticated requests.
basic_auth: user: ${NEO_BASIC_AUTH_USER:-admin} # ${VAR} resolved at deploy; :-default optional password: ${NEO_BASIC_AUTH_PASSWORD} # from env_file / --env / OS env — keep the secret out of git bypass: - /api/* - /webhooks/*
| Key | Type | Description |
|---|---|---|
basic_auth.user | string Required | Username for the HTTP Basic Auth challenge. Supports ${VAR} / ${VAR:-default}. |
basic_auth.password | string Required | Password. Neo bcrypt-hashes it before passing to Caddy. Supports ${VAR} interpolation so you can keep the secret in an env_file. |
basic_auth.bypass | list | Path patterns excluded from authentication (e.g. /api/*, /webhooks/*). Supports glob-style wildcards. |
${VAR}
basic_auth values are interpolated at deploy from the merged env (env block, env_file, --env) then the OS environment, so put the password in a gitignored env_file and reference it as ${NEO_BASIC_AUTH_PASSWORD}. Use ${VAR:-default} for a fallback. Basic auth is also saved to server state, so neo domain and neo caddy update keep the protection instead of dropping it.
${VAR} from a gitignored env_file rather than hardcoding it in .neo.yml. Always pair basic_auth with https: true — credentials sent over plain HTTP are not encrypted. (The resolved password is stored in the server's state.json, same as other service secrets.)
Auth not being enforced?
Caddy applies config through its admin API, so there is no reload step in a healthy setup — a deploy takes effect immediately. If the browser still isn't asking for credentials, check what the proxy is actually serving:
# What Caddy is really serving — AUTH column shows basic or none neo caddy routes # Rebuild every route from server state (all apps, or just one) neo caddy reload neo caddy reload --app my-app
neo caddy reload rewrites domains, upstream, HTTP/HTTPS mode and
basic auth from /etc/neo/state.json. It is the repair tool for a
proxy that has drifted from state — after an interrupted deploy, a manual
docker change, or a route left over from an older Neo version.
neo caddy update is a different thing: it updates the Caddy
image, not the routes.
dev New
Local development settings used exclusively by neo dev. Completely ignored during neo deploy.
| Key | Type | Description |
|---|---|---|
dev.env_file | string | Path to a .env file loaded for local dev (e.g. .env) |
dev.env | map | Dev-only environment variable overrides |
dev.port | integer | Override the local port for neo dev |
dev.volumes | map | Override or add dev-only bind mounts |
dev: env_file: .env # loads your local .env (APP_KEY, secrets, etc.) env: APP_ENV: local APP_DEBUG: "true" APP_URL: http://localhost:8080 volumes: data: ./local-data # short form: override local path for top-level volume cache: ./tmp/cache:/tmp/cache # full form: dev-only bind mount
Env priority for neo dev (highest wins):
dev.env— dev-only overridesdev.env_file— dev .env fileenv— top-level shared varsenv_file— top-level env file- Auto-loaded
.envfrom project root
Volumes defined in the top-level volumes: section are automatically bind-mounted to the project directory during neo dev. Use dev.volumes to override local paths or add dev-only mounts.
${VAR} interpolation in the dev: section. Variables are resolved from the merged env map, then from your OS environment.
hooks New
Shell commands that run locally at specific points in the deploy lifecycle.
| Key | When | Description |
|---|---|---|
hooks.pre_build | Before docker build | Run build steps, generate assets, lint checks |
hooks.post_deploy | After deploy completes | Send notifications, run smoke tests, tag releases |
hooks: pre_build: npm run build && php artisan test post_deploy: curl -X POST https://hooks.slack.com/... -d '{"text":"Deployed!"}'
Hooks run in your local shell with NEO_APP_NAME, NEO_DOMAIN, and NEO_ENVIRONMENT environment variables available.
environments
Named deployment targets that override top-level config. Deploy to a specific environment with --to.
environments: production: domain: app.example.com server: prod-server https: true env_file: .env.production ssl: certificate: certs/prod/fullchain.pem private_key: certs/prod/privkey.pem staging: domain: staging.example.com server: staging-server env: APP_DEBUG: "true" LOG_LEVEL: debug
# Deploy to production neo deploy --to production # Deploy to staging neo deploy --to staging # If multiple environments exist and no --to flag, Neo prompts neo deploy
Environment fields override top-level: domain, server, port, https, env, env_file, env_encrypted, ssl, basic_auth, volumes.
volumes inside each environment to isolate databases and storage per environment. See Per-environment volumes for details.
Deploying Apps
Neo builds your Docker image, transfers it to the server, starts the container, and configures routing — all in one command.
# Deploy current directory neo deploy . # Deploy with explicit options neo deploy . --domain app.example.com --env APP_KEY=base64:... --to production # Deploy a different directory neo deploy ./my-project
Build strategies
Neo automatically chooses the best build strategy based on your environment.
| Strategy | When | How |
|---|---|---|
| Local build | Docker is running locally | Builds on your machine with --platform linux/amd64, then transfers via docker save | ssh docker load |
| Remote build | No local Docker | Packages source as tar.gz, uploads via SCP, builds on server with docker build |
Env var priority
Environment variables are merged from multiple sources. Higher priority overrides lower.
| Priority | Source | Example |
|---|---|---|
| 1 (highest) | --env CLI flag | neo deploy --env LOG=debug |
| 2 | --env-file CLI flag | neo deploy --env-file .env |
| 3 | .neo.yml env | Declared in config |
| 4 | .neo.yml env_file | Path to a .env |
| 5 | .neo.yml env_encrypted | Decrypted .env.encrypted |
| 6 | docker-compose.yml | Auto-detected from compose |
| 7 (lowest) | Server state (redeploy) | Previous deployment values |
Zero-downtime deployment
Neo uses blue-green deployment on redeploys:
Start the new container alongside the old one (app-{name}-next)
Wait for the new container's health check to pass
Switch Caddy routing to the new container — traffic switches instantly
Stop and remove the old container
Rename the new container to the canonical name
Domains & SSL
Caddy handles reverse proxying and TLS certificate management. Three ways to set up domains:
# Set a custom domain neo domain my-app app.example.com # Assign a temporary sslip.io domain with instant SSL neo domain my-app --temp # Use a custom SSL certificate neo domain my-app app.example.com --cert fullchain.pem --key privkey.pem # Toggle the origin route mode (without changing the domain) neo domain my-app --https # HTTPS at the origin (auto-SSL) neo domain my-app --http-only # HTTP only at the origin neo domain my-app --cloudflare-flexible # HTTP origin behind Cloudflare Flexible SSL
Auto SSL (sslip.io) New
Get instant HTTPS without configuring DNS. The --temp flag assigns {app}.{ip}.sslip.io which auto-resolves to your server.
$ neo domain ghost --temp
Temporary domain ready!
URL: https://ghost.your-ip-address.sslip.io
sslip.io resolves to your server IP automatically.
SSL certificate auto-provisioned via Let's Encrypt.
Replace with a real domain later:
neo domain ghost blog.example.com
Custom certificates
Upload your own TLS certificates. Useful for wildcard certs, internal CAs, or when using a cert from a specific provider.
neo domain my-app app.example.com --cert /path/to/fullchain.pem --key /path/to/privkey.pem
ssl: certificate: certs/fullchain.pem private_key: certs/privkey.pem
The certificate is uploaded to /etc/neo/certs/{app}/ on the server and loaded into Caddy.
Wildcard HTTPS New
Serve *.example.com from a single certificate. Neo configures Caddy for free wildcard certificates two ways — pick based on whether your subdomains are known up front or fully dynamic.
Option A — ACME DNS-01 (neo caddy dns)
Provisions a real Let's Encrypt wildcard certificate up front by proving domain ownership through a DNS record. Requires a DNS provider API token (currently Cloudflare). The token is read from a local environment variable and copied to the server as a root-only file — it is never typed interactively.
# Build a DNS-enabled Caddy, store the token, enable wildcard SSL CLOUDFLARE_API_TOKEN=... neo --server prod caddy dns example.com --app my-app # Bind the wildcard to an app afterwards (if --app was omitted) neo domain my-app "*.example.com" --add
| Flag | Description |
|---|---|
--provider <name> | DNS provider for DNS-01 (default: cloudflare) |
--token-env <var> | Local env var holding the API token (default: provider-specific, e.g. CLOUDFLARE_API_TOKEN) |
--app <app> | Bind the wildcard *.domain to an app after setup |
Option B — Guarded on-demand TLS (neo caddy ondemand)
For unbounded, dynamic tenant subdomains where you can't pre-list every hostname. Caddy issues a real certificate for each subdomain on its first request, gated by an ask URL your app controls — it must return 200 only for allowed hostnames.
neo --server prod caddy ondemand example.com --app my-app --replace-domains
| Flag | Description |
|---|---|
--app <app> | App to bind the wildcard to (auto-derives the ask URL from the app) |
--ask-url <url> | URL Caddy calls to allow/deny issuance (required when --app is omitted) |
--replace-domains | Replace the app's extra domains with the wildcard |
*.example.com for production and *.staging.example.com for staging — each getting its own certificate. Re-run once per tree.
*. hostname with neo domain is guarded — it requires DNS-01 or on-demand TLS to be configured first, otherwise Caddy can't issue the certificate.
Cloudflare Flexible SSL New
If your app sits behind Cloudflare in Flexible SSL mode — HTTPS between the browser and Cloudflare, plain HTTP from Cloudflare to your origin — serve the origin over HTTP while still telling the app it's on HTTPS. This prevents redirect loops without disabling TLS at the edge.
# Switch an existing app's origin route to Cloudflare Flexible mode
neo domain my-app --cloudflare-flexible
edge_https: true
Neo sets an HTTP-only origin route and injects the following headers so the app generates correct https:// URLs:
| Header | Value |
|---|---|
X-Forwarded-Proto | https |
X-Forwarded-Ssl | on |
X-Forwarded-Port | 443 |
--cloudflare-flexible (or edge_https: true). --cloudflare-flexible cannot be combined with --https.
Deployment Versions New
Every deploy records the git commit it was built from, so “which code is running?” has an answer. Before this, an image tag carried only a timestamp: two deploys of different commits looked identical, and redeploying the same commit looked like a new version.
$ neo list NAME DOMAIN PORT STATUS VERSION ● shop shop.example.com 8080 running v1.4.2 (a1b2c3d) ● api api.example.com 3000 running 9f01e2a * # * = built from uncommitted changes
One app in detail
$ neo status shop
shop ● on production
Deployment
Version v1.4.2 (a1b2c3d)
Commit a1b2c3d4e5f6789...
Branch main
Image neo-shop:20260818-045536-a1b2c3d
Deployed 2026-08-18T04:55:36Z by alan@macbook
Env digest sha256:6fb787f3a6ad231e
The env digest fingerprints the environment the build ran with, so the same commit deployed twice with different configuration is distinguishable — otherwise “the code didn’t change, why did behaviour?” is unanswerable.
History
$ neo deploys shop
DEPLOYED VERSION BY
● just now v1.4.2 (a1b2c3d) alan@macbook
3h ago 9f01e2a ci@github-actions
2d ago v1.4.1 (7c4d2b1) alan@macbook image pruned
History lives on the server, in /etc/neo/deploys/<app>.jsonl, so
everyone who deploys shares one record — your laptop, a colleague’s, and CI. It
survives a fresh clone, and it is append-only, so two simultaneous deploys cannot
overwrite each other’s entries. Entries marked image pruned are
informational: Neo keeps only the last few images, so those builds are no longer on disk.
Inside the container
Each deploy injects variables describing itself, so the running container can identify its own build:
$ neo run shop -- printenv | grep NEO_GIT NEO_GIT_COMMIT=a1b2c3d4e5f6789... NEO_GIT_SHORT_COMMIT=a1b2c3d
| Variable | Example |
|---|---|
NEO_DEPLOYMENT_ID | 20260818-045536-a1b2c3d |
NEO_GIT_COMMIT | full sha |
NEO_GIT_SHORT_COMMIT | a1b2c3d |
NEO_GIT_BRANCH | main |
NEO_GIT_TAG | v1.4.2 (only when the commit is tagged) |
NEO_DEPLOYED_AT | RFC3339 timestamp |
.neo.yml interpolation runs, so you can reference them
in your own config — Neo needs no knowledge of the tool you’re feeding:
env: SENTRY_RELEASE: "${NEO_GIT_COMMIT}"
Not using git?
Nothing requires it. A scaffolded template or a source tarball simply shows no version.
In CI, where a shallow checkout often has no usable repository, Neo falls back to
NEO_GIT_COMMIT, GITHUB_SHA or CI_COMMIT_SHA.
* everywhere it
is shown. The recorded commit then describes only part of what shipped — which is how
“production is broken but git says the fix is in” happens.
Checking a deploy from CI
# fail the job if the running commit isn't the one we just built
[ "$(neo status shop --json | jq -r .deployment.commit)" = "$GITHUB_SHA" ] || exit 1
Syncing State New
CLI commands like neo domain and neo env set update server state but don't modify .neo.yml. Use neo sync to write server state back to your config.
# Preview changes (dry run) $ neo sync my-app --dry-run Syncing my-app: server → .neo.yml (dry run) ~ domain example.com → my-app.1.2.3.4.sslip.io ~ https false → true + env.NEW_VAR hello 3 change(s) detected. Run without --dry-run to apply. # Apply changes $ neo sync my-app Write 3 change(s) to .neo.yml? (y/n): y ✓ .neo.yml updated (3 changes)
What gets synced
| Field | Direction |
|---|---|
| domain | Server → .neo.yml |
| port | Server → .neo.yml |
| https | Server → .neo.yml |
| env vars | Server → .neo.yml (full replace) |
| volumes | Server → .neo.yml (additions only) |
| workers | Server → .neo.yml |
Per-Environment Volumes
When you define volumes inside each environment block (instead of top-level), Neo creates separate Docker volumes per environment. This ensures production and staging never share a database or storage directory.
environments: production: env_file: .env.production volumes: data: /var/www/html/database storage: /var/www/html/storage staging: env_file: .env.staging volumes: data: /var/www/html/database storage: /var/www/html/storage
How naming works
Neo namespaces volumes by combining the app name and environment:
| Environment | Volume name | Description |
|---|---|---|
| production | {app}-production-data | Production database |
| production | {app}-production-storage | Production uploads & cache |
| staging | {app}-staging-data | Staging database |
| staging | {app}-staging-storage | Staging uploads & cache |
.neo.yml are shared across all environments. Move them inside each environments block to isolate them. This is especially important for databases — you don't want staging writes hitting your production SQLite file.
When to use which
| Scenario | Approach |
|---|---|
| Database files, user uploads | Per-environment volumes (isolated) |
| Shared build artifacts, static assets | Top-level volumes (shared) |
| Single environment only | Either — no difference |
Team Access (SSH Keys)
Give teammates their own access to a server with neo key — no shared passwords, no copying private key files, no GitHub. Everyone uses their own key; the admin authorizes or revokes each person individually.
Two roles
| Role | Who they are | Commands they run |
|---|---|---|
| Admin | Owns the server — ran neo init (or attached it) and already has access. | neo key add, neo key list, neo key remove |
| Teammate | Wants access to the admin's server. Starts with nothing configured. | neo key show, then neo attach (or neo deploy) |
The only thing that passes between people is the teammate's public key — safe to send over Slack or email. Private keys never leave their owner's machine.
neo key add, list, and remove all run on the server, so the admin must have it in their own config before authorizing anyone. Fresh server → neo init <user@host>. Server that was set up on another machine → neo attach <user@host>. Until then, those commands fail with "no server selected — run: neo init".
# [Teammate] Generate + print your public key to send to the admin neo key show # [Admin] Authorize a teammate's public key on the server neo key add "ssh-ed25519 AAAAC3Nz... [email protected]" # Target a specific server (otherwise uses the current one, # or asks which when you have more than one configured) neo key add --server production "ssh-ed25519 AAAAC3Nz... [email protected]" # [Admin] List authorized keys (marks your own) neo key list # [Admin] Revoke a key by its number from the list neo key remove 2
Step-by-step workflow
[Admin] Have the server in your config. If you ran neo init on this machine it already is. If it was set up elsewhere, run neo attach [email protected] first — that is what lets neo key add reach the server.
[Teammate] Run neo key show — no server or config needed yet. Neo generates a key at ~/.neo/neo_ed25519 if one doesn't exist, then prints the single-line public key.
[Teammate → Admin] Send the key. It looks like ssh-ed25519 AAAAC3Nz... name@host. Safe to share — it's a public key.
[Admin] Run neo key add "<key>". Neo appends it to ~/.ssh/authorized_keys on the server and prints the server: line (e.g. [email protected]) to send back to the teammate.
[Teammate] Connect. Run neo attach [email protected] to register the server locally — the dashboard and every command then work. (Deploy only? Skip attach and put server: [email protected] in .neo.yml, then neo deploy.) The teammate never runs neo init — that's the admin's job and would wipe the server's apps.
name: my-app domain: app.example.com server: root@your-server-ip # full user@host — the @ skips local config
server: value must be user@host
Use the full user@host (e.g. [email protected]), not a bare name. The @ is what lets a teammate connect without the server being registered in their ~/.neo/config.json. A bare name like server: production is looked up in local config and fails with "no server selected — run: neo init" for someone who never ran neo init. neo key add prints the exact line to copy.
neo deploy reads server: from .neo.yml. For any other command, pass the same address with the global flag — neo logs my-app --server [email protected]. To make the bare neo dashboard and all commands work without a flag, register the server once with neo attach [email protected] — it adds the server to ~/.neo/config.json with no setup and no risk to a live server (see Attaching an existing server).
Attaching an existing server
neo attach registers a server that was already set up by someone else into your local config, so the dashboard, neo list, neo logs, and every other command work — without a --server flag on each call. Unlike neo init, it never installs Docker or Caddy and never overwrites the server's state, so it is safe to run against a live server with apps deployed.
# Add an already-initialized server to your local config neo attach [email protected] # Then everything works with no flag neo list neo logs my-app
neo init only on a fresh server — it installs Docker + Caddy and writes a new state file, which would wipe the app registry of a server that is already running apps. To join a server someone else already initialized, always use neo attach.
Listing keys
neo key list shows every authorized key on the server with a number, and marks your own key with (you):
$ neo key list Authorized keys: #1 alan@macbook (you) #2 [email protected] #3 ...Xk9mRpQ2aZ Remove a key with: neo key remove <number>
Revoking access
Run neo key remove <number> with the number from neo key list. Neo will not let you remove your own key — doing so would lock you out.
neo key remove 2 # revokes key #2
neo key add with a key that is already present is a no-op — the key is not duplicated in authorized_keys.
Domain Redirects New
Redirect any domain to another URL — no app, sidecar, or service required. Neo configures Caddy directly, so the redirect is live in seconds with auto-SSL provisioned for the source domain.
# 301 permanent redirect (default) neo redirect add vxero.dev vxero.com # 302 temporary redirect neo redirect add old.api.com new.api.com --temporary # List all active redirects neo redirect list # Remove a redirect neo redirect remove vxero.dev
How it works
| Detail | Behaviour |
|---|---|
| Default redirect type | 301 permanent. Pass --temporary for a 302. |
| Path preservation | The full request path is forwarded. vxero.dev/blog redirects to vxero.com/blog. |
| Auto-SSL | Caddy automatically provisions a Let's Encrypt certificate for the source domain — point DNS first. |
| Scheme normalisation | The destination is always rewritten to https://. Pass https:// explicitly if needed. |
| Conflict detection | Adding a redirect for a domain already claimed by an app returns an error. Remove the domain from the app first. |
Example output
$ neo redirect add vxero.dev vxero.com ✓ Redirect active! From vxero.dev To https://vxero.com Type 301 permanent SSL auto-provisioned by Caddy Paths are preserved: vxero.dev/blog → vxero.com/blog
neo redirect add. Use neo redirect add ... --temporary to test without waiting for DNS propagation (no SSL for the source domain until DNS is correct).
Shared Services
A shared service is a database or cache container that runs at the server level, not inside any single app. Multiple apps can connect to the same instance — useful on small VMs where spinning up a separate database per app wastes hundreds of MB of RAM.
Supported types
| Type | Default image | What it provisions |
|---|---|---|
mysql | mysql:8 | Database + dedicated user, injects DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD |
mariadb | mariadb:11 | Same as MySQL — drop-in compatible |
postgres | postgres:16 | Database + dedicated role, injects DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD |
redis | redis:7-alpine | Shared cache/queue, injects REDIS_HOST, REDIS_PORT |
Usage
# Create a shared service (runs once on the server) neo service create mysql neo service create postgres neo service create redis # See all services and their status neo service list # Show connection details anytime (host, port, user, password, URL) neo service info mysql # Other operations neo service logs mysql neo service start|stop|restart mysql neo service remove mysql
neo service create prints the host, port, database, username, and password. Lost them? Run neo service info <name> to reprint everything — including a ready-to-use connection URL. The credentials are kept in server state, so they are never a one-time reveal. Wire them into your app with neo service link, neo env set, or your .neo.yml env: block.
Databases & Data
Neo can run databases in Docker via shared services or bundled templates. This works well for small projects, but production workloads with real user data should use managed database services.
Three approaches
| Approach | How | Best for | Data safety |
|---|---|---|---|
| Bundled | Template includes DB (e.g. Ghost + MySQL) | Hobby projects, testing | You manage backups |
| Shared service | neo service create mysql |
Multi-app VPS, saving RAM | You manage backups |
| Managed (external) | Neon, PlanetScale, Supabase, RDS | Production with real users | Provider handles backups, replication, failover |
When to use what
| Scenario | Recommendation |
|---|---|
| Side project, blog, personal site | Bundled or shared service — simple, all on one VPS |
| 3–5 apps on one $12/mo VPS | Shared service — one Postgres for all apps saves ~200MB RAM each |
| SaaS with paying customers | Managed database — automated backups, point-in-time recovery, replication |
| Regulatory / compliance requirements | Managed database — encryption at rest, audit logs, certifications |
| Data you cannot afford to lose | Managed database — Docker volumes are not a backup strategy |
Why managed databases for production
Docker databases on a single VPS have real limitations that managed services solve:
| Capability | Docker on VPS | Managed service |
|---|---|---|
| Automated backups | Manual (neo backup) | Daily, with retention policy |
| Point-in-time recovery | Not available | Restore to any second |
| Replication / failover | Not available | Automatic |
| Transaction-safe snapshots | Volume tarballs only | WAL-based, consistent |
| Security patches | Manual image updates | Applied automatically |
| Monitoring | None | Slow queries, connections, disk |
| Scaling | Vertical only (bigger VPS) | Read replicas, auto-scaling |
neo backup creates volume-level tarballs — it stops the container, tars the volume, and restarts. This is not transaction-safe. For Docker databases, run mysqldump or pg_dump before backup for consistency.
Recommended managed providers
| Provider | Database | Free tier |
|---|---|---|
| Neon | PostgreSQL | 512MB storage, autoscaling |
| PlanetScale | MySQL | 5GB storage, 1B reads/mo |
| Supabase | PostgreSQL | 500MB storage, 2 projects |
| Redis Cloud | Redis | 30MB, 1 database |
| DigitalOcean | Postgres / MySQL / Redis | From $15/mo |
| AWS RDS | All major engines | 750hr/mo free tier (12 months) |
Connecting to an external database
Point your app at a managed database by setting the connection string:
# Set database URL (app restarts automatically) neo env set my-app DATABASE_URL=postgres://user:[email protected]/mydb # Or set individual vars neo env set my-app \ DB_HOST=db.neon.tech \ DB_PORT=5432 \ DB_DATABASE=mydb \ DB_USERNAME=user \ DB_PASSWORD=secret
env: DB_CONNECTION: pgsql env_file: .env.production # contains DATABASE_URL (not committed to git)
.env.production (gitignored) and reference it via env_file in .neo.yml. Never commit secrets to your repository.
Backing up Docker databases
If you do run databases in Docker, supplement Neo's volume backups with database-native dumps:
# MySQL dump neo ssh docker exec svc-mysql mysqldump -u root -p'$PASS' --all-databases > /var/backups/mysql-$(date +%F).sql # PostgreSQL dump docker exec svc-postgres pg_dumpall -U postgres > /var/backups/pg-$(date +%F).sql # Download to your laptop scp root@your-server:/var/backups/mysql-2026-03-23.sql ./backups/
Server Management
# List servers neo servers # Switch active server neo use production # Remove a server from config neo servers remove old-server # SSH into server neo ssh # Target a specific server for any command neo list --server staging
Firewall (CrowdSec)
Neo ships an optional firewall powered by CrowdSec — a free, open-source intrusion-prevention engine. It watches your logs, auto-bans brute-force attackers with an nftables bouncer, and pulls in a community-maintained blocklist of known-malicious IPs. Everything runs on your server; nothing is sent to Neo.
Install
# Install CrowdSec + the nftables bouncer on the active server
neo firewall install
Install is idempotent — running it again on an already-protected server is a no-op. Neo records the firewall state in /etc/neo/state.json.
Manage decisions
# Service status + active decision count neo firewall status # List every active decision (ban) neo firewall list # Manually ban / unban an IP or range neo firewall block 203.0.113.10 neo firewall unblock 203.0.113.10 # Update the engine, bouncer, and community blocklists neo firewall update
| Command | What it does |
|---|---|
firewall install | Installs CrowdSec + nftables bouncer on the server |
firewall status | Shows service health and how many IPs are currently banned |
firewall list | Lists active decisions (IP, type, origin, duration, reason) |
firewall block <ip> | Manually bans an IP or CIDR range |
firewall unblock <ip> | Removes a ban |
firewall update | Updates the engine, bouncer, and blocklists |
block/unblock only when you want to manage an IP by hand.
Stealth Mode
Stealth mode hides your server from IP-based discovery. When enabled, Neo removes Caddy's catch-all welcome page, so direct requests to the server's IP get no response — only your configured domains serve traffic. This stops scanners from fingerprinting the box.
# Toggle stealth on (removes the IP welcome page) neo stealth # Run again to toggle it back off (restores the welcome page) neo stealth
CLI Reference
| Command | Description |
|---|---|
neo | Interactive TUI dashboard |
neo init <host> | Initialize a fresh server (Docker + Caddy) |
neo attach <host> | Add an already-initialized server to local config (no setup, no overwrite) |
neo config init | Scaffold a new .neo.yml for the project (--yes for defaults) |
neo config generate | Generate .neo.yml from docker-compose.yml |
neo deploy [path] | Build and deploy a Dockerfile project |
neo install [app] | Scaffold a bundled app template into a folder |
neo list | List apps on the server |
neo status [app] | Server health, or full detail for one app |
neo deploys <app> | Deployment history — what shipped, when, by whom |
neo logs <app> | Stream container logs |
neo domain <app> [domain] | Set domain. --temp for sslip.io, --cert/--key for custom SSL, --https/--http-only to switch origin mode, --cloudflare-flexible for Cloudflare Flexible SSL |
neo caddy dns <domain> | Wildcard HTTPS via ACME DNS-01 (free wildcard cert) |
neo caddy ondemand <domain> | Guarded on-demand wildcard TLS for dynamic subdomains |
neo sync <app> | Sync server state back to .neo.yml |
neo env <app> | View env vars (secrets masked) |
neo env set <app> K=V | Set env var (auto-restarts) |
neo env unset <app> KEY | Remove env var |
neo env import <app> .env | Bulk import from file |
neo start|stop|restart <app> | App lifecycle control |
neo update <app> | Pull latest image and redeploy |
neo remove <app> | Remove app and routing |
neo key show | Generate (if needed) and print your Neo public key |
neo key add <pubkey> | Authorize a teammate's public key on the server |
neo key list | List authorized keys on the server (marks your own) |
neo key remove <number> | Revoke a key by its number from neo key list |
neo redirect add <from> <to> | Redirect a domain to a URL (301 by default). Pass --temporary for 302. |
neo redirect list | List all active domain redirects |
neo redirect remove <from> | Remove a domain redirect |
neo service create [type] | Create shared service (prints connection info) |
neo service info <svc> | Show connection details (host, port, user, password, URL) |
neo servers | List configured servers |
neo servers remove <name> | Remove server from config |
neo use <name> | Switch active server |
neo ssh | SSH into current server |
neo version | Show version, check for updates |
neo upgrade | Self-update to latest version |
neo backup <app> | Backup data volumes |
neo volumes | List Docker volumes on server |
Global flags
| Flag | Description |
|---|---|
--server <name|user@host> | Target a specific server — by config name, or a full user@host to connect without registering it locally |
Server Requirements
| Requirement | Details |
|---|---|
| OS | Ubuntu 24.04+, Debian (any version), Fedora 39+, or CentOS / RHEL / AlmaLinux / Rocky 9+. The package manager is auto-detected (apt for Debian/Ubuntu, dnf for RPM-based distros). |
| Access | Root SSH access with key-based authentication |
| Ports | 80 (HTTP), 443 (HTTPS) must be open |
| RAM | Minimum 512MB. 1GB+ recommended for builds. |
neo init detects the OS and version from /etc/os-release and shows a clear error if unsupported.
What Neo installs on your server
When you run neo init, Neo touches exactly two things on your VM:
| Installed | Why |
|---|---|
| Docker | Runs all containers — your app, workers, sidecars, shared services |
| Caddy | Reverse proxy, handles HTTPS and domain routing |
That's it. Neo does not install PHP, Python, Go, Node.js, Ruby, Java, or any other language runtime. It does not install database clients, build tools, package managers, or any other system software.
neo ssh and do whatever you need.
What Neo is not
Neo is deliberately focused. Before adopting it, it's worth being clear about what it does not do.
| Not supported | Why / what to use instead |
|---|---|
| Clusters & multi-node orchestration | Neo manages one app on one server per deploy. It does not spread containers across multiple machines, balance load between nodes, or coordinate distributed state. Use Kubernetes or Nomad if you need that. |
| Docker Swarm | Neo does not use or interact with Docker Swarm mode. Every container is a standalone docker run — no Swarm services, no overlay networks, no replicas. |
| Auto-scaling | Neo does not add or remove servers based on load. Scaling means either upgrading your VPS vertically (more RAM/CPU) or deploying the same project to an additional server manually. |
| Blue/green or canary deploys | Neo does zero-downtime deploys by starting the new container before stopping the old one. It does not split traffic between versions or support staged rollouts. |
| Managed databases | Neo can run databases in Docker containers but does not manage replication, failover, or automated backups for them. For production data, use a managed service (Neon, Supabase, RDS). |
git push-style deploys with zero infrastructure overhead, Neo is the right tool. If you need multi-region failover, cluster scheduling, or dynamic scaling, you need a different tool.
Architecture
Neo is a single Go binary that runs on your local machine. It has no server-side component — everything happens over SSH.
Container naming
| Container | Pattern | Example |
|---|---|---|
| App | app-{name} | app-ghost |
| Worker | app-{name}-worker-{worker} | app-ghost-worker-queue |
| Sidecar | svc-{name}-{sidecar} | svc-ghost-redis |
| Shared service | svc-{name} | svc-mysql |
| Caddy | neo-caddy | neo-caddy |
Docker network
All containers join the neo Docker network. Containers reference each other by name (e.g. svc-mysql:3306).
Volume naming
Volumes are prefixed with the app name: {app}-{volume}. For example, an app named ghost with a volume content creates ghost-content.