No Kubernetes. No platform lock-in. Point neo at a server, run four commands, ship.
Any language, any Dockerfile — your VPS, your data.
Neo can deploy from a Dockerfile alone — but then your domain, port and env live only on the server. Commit a .neo.yml and the deployment is described in the repo, reviewable and repeatable.
Your project structure
my-app/ ├── Dockerfile ← you already have this ├── .neo.yml ← start here — neo config init ├── src/ │ └── ... └── .env.production ← optional, loaded by neo.yml
Without a .neo.yml, neo falls back to your Dockerfile’s EXPOSE and the directory name — fine for a first deploy.
Everything past that — environments, workers, release commands, encrypted env, basic auth — is declared here. Run neo config init to scaffold one.
Minimal .neo.yml
# That's all you need. name: my-app port: 3000
Port is auto-detected from EXPOSE in your Dockerfile — so you can skip port: entirely too.
# Add volumes so storage survives redeploys. name: my-laravel-app volumes: storage: /var/www/html/storage workers: queue: command: php artisan queue:work --tries=3 dev: env_file: .env # loads APP_KEY, secrets environments: production: env_file: .env.production
Uses serversideup/php image. EXPOSE 8080 in Dockerfile — neo detects port automatically. Run neo dev locally, neo deploy --to production for prod.
# Rails: persist uploads and the sqlite db. name: my-rails-app port: 3000 env_file: .env.production volumes: storage: /rails/storage
Rails 7+ uses RAILS_MASTER_KEY for credentials. Set it via neo env set my-rails-app RAILS_MASTER_KEY=... after first deploy.
# Django: media files + static assets. name: my-django-app port: 8000 env_file: .env.production volumes: media: /app/media static: /app/staticfiles
Run python manage.py collectstatic and migrate in your Dockerfile CMD or as a startup script.
# .NET: standard port 8080 in dotnet/aspnet images. name: my-dotnet-app port: 8080 env_file: .env.production env: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080
The mcr.microsoft.com/dotnet/aspnet base image defaults to port 8080 in .NET 8+. Set ASPNETCORE_URLS to match.
# WordPress: persist uploads + MySQL data. name: my-wordpress port: 80 env_file: .env.production volumes: uploads: /var/www/html/wp-content/uploads
Use neo service create mysql my-db to provision a shared MySQL, then neo service link my-db my-wordpress to inject DB_* env vars automatically.
Run these once per project. Redeploys after code changes are just neo deploy.
One command. Supports macOS, Linux, and Windows.
Point neo at a fresh Ubuntu, Debian, Fedora, or RHEL-family (CentOS / AlmaLinux / Rocky) VPS. Docker, Caddy, and the neo network are set up automatically.
Two lines max. Or skip it entirely — neo reads EXPOSE from your Dockerfile and uses the directory name.
name: my-app port: 3000
neo builds your image, transfers it over SSH, starts the container, passes the health check, and provisions SSL — all in one command.
After your first deploy, redeploys are just neo deploy — zero downtime, same four seconds.
Every field annotated. Copy, paste, remove what you don't need. Every field is optional.
name: my-node-app # container name (default: directory name) domain: api.example.com # SSL auto-provisioned via Let's Encrypt + Caddy port: 3000 # must match EXPOSE and what your server listens on env_file: .env.production # secrets: DB_URL, API keys, JWT_SECRET, etc. env: # non-secret defaults committed to .neo.yml NODE_ENV: production PORT: "3000" LOG_LEVEL: info TZ: UTC volumes: # Docker volumes — survive every redeploy uploads: /app/uploads # user-uploaded files data: /app/data # sqlite db or local flat files workers: # same image, different entrypoint command cron: command: node dist/cron.js # scheduled jobs runner worker: command: node dist/worker.js # queue consumer (BullMQ, etc.) environments: production: domain: api.example.com env_file: .env.production staging: domain: api-staging.example.com env: NODE_ENV: staging LOG_LEVEL: debug
Never commit secrets to .neo.yml. Put JWT_SECRET, DATABASE_URL, API keys in .env.production (git-ignored) and reference it via env_file.
name: my-laravel-app domain: myapp.example.com port: 8080 # serversideup/php image exposes 8080 env_file: .env.production # APP_KEY, DB_PASSWORD, MAIL_*, etc. env: APP_ENV: production APP_DEBUG: "false" LOG_CHANNEL: stderr SESSION_DRIVER: database CACHE_STORE: database QUEUE_CONNECTION: database DB_CONNECTION: sqlite TRUSTED_PROXIES: "*" # required — Caddy is a reverse proxy volumes: storage: /var/www/html/storage database: /var/www/html/database workers: queue: command: php artisan queue:work --tries=3 --max-time=3600 --memory=256 scheduler: command: php artisan schedule:work environments: production: domain: myapp.com env_file: .env.production staging: domain: staging.myapp.com env: APP_DEBUG: "true"
For PostgreSQL/MySQL: neo service create postgres my-db → neo service link my-db my-laravel-app (injects DATABASE_URL automatically).
name: my-rails-app domain: myapp.example.com port: 3000 env_file: .env.production env: RAILS_ENV: production RAILS_SERVE_STATIC_FILES: "true" RAILS_LOG_TO_STDOUT: "true" volumes: storage: /rails/storage workers: sidekiq: command: bundle exec sidekiq -C config/sidekiq.yml
Set RAILS_MASTER_KEY after first deploy: neo env set my-rails-app RAILS_MASTER_KEY=your_key_here
name: my-django-app domain: myapp.example.com port: 8000 env_file: .env.production env: DJANGO_SETTINGS_MODULE: myapp.settings.production PYTHONUNBUFFERED: "1" volumes: media: /app/media static: /app/staticfiles workers: celery: command: celery -A myapp worker -l info --concurrency=4
Run collectstatic and migrate in your Dockerfile CMD or entrypoint script.
name: my-dotnet-app domain: myapp.example.com port: 8080 env: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 volumes: keys: /app/keys
The keys volume is critical — ASP.NET Data Protection keys must survive redeploys or all user sessions are invalidated.
name: my-wordpress domain: blog.example.com port: 80 volumes: uploads: /var/www/html/wp-content/uploads plugins: /var/www/html/wp-content/plugins themes: /var/www/html/wp-content/themes
Database: neo service create mysql my-db → neo service link my-db my-wordpress
All commands use the same pattern: resolve the server, connect via SSH, operate, and save state.
neoneo init <host>neo deploy .docker-compose.yml, .env, or .neo.yml. Supports --env KEY=VAL, --env-file .env, and --to <env> flags.neo env <app>neo env set <app> K=Vneo env unset <app> KEYneo env import <app> .env.env file.neo listneo logs <app>--tail, --follow, and --worker <name> flags.neo domain <app> <domain>neo stop|start|restart <app>neo update <app>neo remove <app>neo sync <app>.neo.yml. Domain changes, env vars, HTTPS toggles — all captured.neo serversneo use <name>--server.neo sshneo backup <app>neo restore <app> <file>neo volumesneo versionneo upgradeneo init will detect your OS and show an error if unsupported.
Any server. Any language. Your data stays on your machine.