A community question-and-answer platform written in plain, framework-free PHP.
Live site: asknest.me · Source: github.com/amazing-things/asknest
This document takes you from an empty server to a working, production-ready installation. Read it in order the first time. Every step matters, and a few of them are easy to miss, so I have called those out where they occur.
- What you are installing
- Server requirements
- Getting the files onto the server
- Choosing your document root
- Creating the database
- The secrets file
- open_basedir
- File permissions
- Non-secret configuration
- Your first admin account
- Web push notifications
- Image uploads
- Scheduled jobs
- Admin panel configuration
- The AI features
- Premium memberships
- Legal and branding pages
- Security notes
- Verifying the installation
- Routine operations
- Troubleshooting
- Before you go live
- License
AskNest is a community question-and-answer platform written in plain PHP. There is no Laravel, no Symfony, no framework to learn. The application ships with its own small core in app/Core: a router, a PDO database wrapper, a session handler, a CSRF guard, a view renderer, and an auth layer. If you know PHP, you can read the whole thing.
Users register, ask questions, answer them, reply to answers, react with emoji, vote in polls, keep a private diary, follow each other, earn reputation, and appear on a leaderboard. Staff get a moderator panel and an admin panel. There is a premium membership tier, a support ticket system, an AI assistant layer, and web push notifications.
The layout of the code is straightforward:
app/Core the mini framework
app/Controllers one controller per feature area
app/Services the actual business logic, about fifty classes
app/Views PHP templates
routes/web.php every URL in the application, in one readable file
config/app.php non-secret configuration
database/ schema and historical migrations
tools/ command line scripts, mostly for cron
lib/phpmailer bundled PHPMailer
vendor/ composer dependencies
cloudflare/ optional media worker for R2 storage
storage/ logs, cache, local uploads
public/ an optional, safer document root
Two things about the architecture will save you confusion later.
First, background work does not all run from cron. Look at app/Core/Application.php and you will see that on each web request the application randomly fires small maintenance tasks: expired credential cleanup, account deletion purges, queued activity mail, the weekly digest, AI moderation, birthday notifications. They are wrapped in try/catch and their failures are logged rather than shown. This means a site with no traffic does less housekeeping than a busy one, and it means an error in a background task shows up in your PHP error log rather than on screen.
Second, configuration is split deliberately. Anything secret lives in a file outside the web root. Anything not secret lives either in config/app.php or in the site_settings database table, editable from the admin panel. Nothing secret is committed anywhere in this project.
- PHP 8.3 or newer. The dependencies technically allow 8.2, but the admin health check warns below 8.3 and that is the version this was developed and run on.
- Required PHP extensions (the application checks for them on the admin system page):
pdo_mysql,openssl,mbstring,curl,json,gd.- The
gdextension must be built with WebP support. Uploaded images are converted to WebP, so agdwithout WebP will break image uploads specifically while leaving the rest of the site working, which is a confusing failure to debug.
- The
- APCu is optional. If present, site settings are cached in shared memory. If absent, the application falls back to database caching and simply runs a little slower. Nothing breaks.
- MySQL 8.0 or MariaDB 10.6 or newer. The schema uses utf8mb4 throughout and the database connection forces strict SQL mode, so a lax older MySQL will start rejecting things it used to accept. That is intentional.
- Apache with
mod_rewriteandmod_headersenabled. The routing and all the security headers live in.htaccess. On nginx you would need to translate that file by hand, which is doable but is not covered here. - Composer, to install the one production dependency.
- Command line access for cron. The cron system writes to the system crontab, so a hosting plan that does not give you crontab access will need the jobs registered manually through its own panel.
The recommended way is to clone the public repository from GitHub straight onto the server. The location does not matter as long as PHP can read it, but somewhere like /var/www/asknest or /www/wwwroot/asknest is typical.
# Go to where you keep your sites
cd /var/www
# Clone the repository. This creates a directory named "asknest".
git clone https://github.com/amazing-things/asknest.git
cd asknestIf you use SSH keys with GitHub, clone over SSH instead:
git clone git@github.com:amazing-things/asknest.gitTo pick up later updates you just pull inside that directory:
cd /var/www/asknest
git pull origin mainIf you cannot use git on the server, download a ZIP from the GitHub page (the green Code button → Download ZIP, or a tagged release under Releases) and extract it into the same location.
One thing the clone does not include: the composer dependency directory (vendor/) is intentionally excluded from git. You install it in the next step, so a fresh clone is expected to have no vendor/ folder yet.
Then install the composer dependency:
composer install --no-dev --optimize-autoloaderThere is only one production dependency, minishlink/web-push, used for browser push notifications. Everything else is either bundled in lib/ or written in app/. Running composer install creates the vendor/ directory and the autoloader the application needs to boot.
This is the single most important decision in the installation, and the project supports two layouts. Pick one and be consistent.
The safer layout points your virtual host at the public/ directory. In that case app/, config/, database/, storage/, tools/, vendor/ and bootstrap/ all sit one level above the web root and are physically unreachable over HTTP. The file public/index.php exists exactly for this, and it loads the bootstrap from the parent directory. Use this if you control the virtual host. It is the layout I would recommend to anyone.
The compatible layout points your virtual host at the project root, where the top level index.php lives. This works on shared hosting where you cannot move the document root. Here the sensitive directories are inside the web root, and they are protected only by rules in .htaccess. Those rules are solid, but they are one misconfiguration away from being bypassed, which is why the public/ layout is preferable when you have the choice.
You will notice both layouts have their own copy of the error pages, the assets directory, service-worker.js and favicon.ico. That duplication is deliberate so that either root is complete on its own. When you edit a stylesheet or a script, edit the copy inside whichever root you actually serve. Editing assets/css and then wondering why nothing changed, because the server is really reading public/assets/css, is a mistake that is easy to make and annoying to spot.
The two .htaccess files differ in exactly one line. The root copy contains a rule blocking direct access to app, bootstrap, config, database, lib, routes, storage, tools and vendor. The public copy omits it, because in that layout those directories are not below the web root at all.
Create an empty database and a user that owns it:
CREATE DATABASE asknest CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'asknest_user'@'localhost' IDENTIFIED BY 'a-strong-password';
GRANT ALL PRIVILEGES ON asknest.* TO 'asknest_user'@'localhost';
FLUSH PRIVILEGES;Then import the schema:
mysql -u asknest_user -p asknest < database/schema.sqlThe file database/schema.sql is the complete current structure. It creates all 65 tables and contains no data. It is the only database file you need for a fresh installation.
The other SQL files in database/ are the historical migrations that produced that schema over time. Do not replay them on a fresh install. Several of them drop or rename objects that no longer exist in the current structure, and applying them to an already-correct database will damage it. They are kept for reference, and for upgrading an older existing deployment. When you do need to apply one, there is a runner that handles it:
php tools/run_sql_migration.php database/some_file.sqlThe runner splits on statement boundaries and deliberately ignores duplicate column errors, so running the same file twice is safe.
Every credential the application needs lives in one file, outside the web root. There is no .env inside the project, and nothing sensitive is hardcoded anywhere.
The application resolves each setting in two steps. It first looks for a real environment variable, checking $_SERVER, then $_ENV, then getenv. If the value is missing or empty, it falls back to reading a secrets file. The path of that file comes from the ASKNEST_SECRETS_FILE environment variable, and if that is not set either, it defaults to /etc/asknest/asknest.env.
That fallback order is worth understanding. It means you can override any single value with a real environment variable without touching the file, which is handy for staging environments and for one-off command line runs.
The template config/asknest.env.example lists every supported key with comments explaining what it does and how to generate it. Copy it into place:
sudo mkdir -p /etc/asknest
sudo cp config/asknest.env.example /etc/asknest/asknest.env
sudo chown root:www-data /etc/asknest/asknest.env
sudo chmod 640 /etc/asknest/asknest.env
sudo nano /etc/asknest/asknest.envAdjust the group to match whichever user your PHP runs as. The file must not be world readable. It contains your database password.
The file accepts two syntaxes, one pair per line, and you can mix them:
ASKNEST_DB_PASS=value
SetEnv ASKNEST_DB_PASS valueLines beginning with a hash are ignored. Surrounding quotes are stripped, so quoted and unquoted values are equivalent. There is no variable expansion and no multi-line support, so a value containing a newline is not possible.
Fill in the database block first, since nothing works without it. Then generate the encryption key. Then the mail block. Push and R2 can wait until later sections of this guide.
⚠️ One warning that deserves emphasis.ASKNEST_DATA_ENCRYPTION_KEYencrypts stored sensitive data, currently moderator application contact details and users' saved AI provider keys. The value you supply is hashed into the actual key, so any string works, but generate a real random one withopenssl rand -hex 32. Back it up somewhere safe. If you lose it or change it later, everything encrypted with the old key becomes permanently unreadable. There is no recovery path.
In development you may leave the key empty and the application will derive a fallback from other config values, but only when app.env in config/app.php is set to local, dev, development, test or testing. In production an empty key throws an exception the first time encrypted data is touched.
If your PHP runs with open_basedir, and most managed hosting panels enable it, PHP is restricted to the directories you list. The project ships two .user.ini files, one at the root and one in public/, both containing placeholder paths:
open_basedir=/path/to/webroot/:/tmp/:/path/to/secrets/Edit both to your real paths. Three entries are needed: your web root, a temporary directory, and the directory holding the secrets file. Forgetting the third one produces a particularly unhelpful symptom, where the application boots but behaves as if every credential is empty, because PHP silently cannot read the secrets file.
If your PHP does not use open_basedir, you can ignore these files or delete them.
The web server user needs write access to two trees:
chown -R www-data:www-data storage uploads
chmod -R 775 storage uploadsInside storage, the cache subdirectory holds computed JSON for the trending and leaderboard pages, runtime and logs hold operational files, and uploads holds locally stored images when you are not using R2. The cron job scripts also append their output to log files directly in storage.
The uploads directory at the web root is where images become publicly reachable. It ships with its own .htaccess that disables PHP execution inside it, through several overlapping mechanisms. Do not remove that file. It is the difference between an image upload feature and a remote code execution hole.
A detail worth knowing: the upload service decides where to write at runtime. It prefers a public/ directory if one exists, otherwise the project root, and it will also accept the real DOCUMENT_ROOT if that path sits inside the project. So uploads follow whichever layout you chose in section 4 without any configuration on your part.
Open config/app.php. The top half is the loader machinery described in section 6 and you should not need to touch it. The array at the bottom is yours.
base_url— set to your real domain, including the scheme and with no trailing slash. This value matters more than it looks. It is used to build links in outgoing email, and it determines the cookie domain, so getting it wrong produces broken email links and sessions that do not stick.timezone— set to your local zone. It ships asEurope/Istanbul. This affects every displayed timestamp and, importantly, when the daily and scheduled cron jobs consider a day to have rolled over.env/debug— leaveenvasproductionanddebugasfalseon a live site. Settingenvto a development value also enables the encryption key fallback described earlier, which you do not want in production.
The remaining values — the site name shown in the interface, the pagination size, the session cookie name, the CSRF field name and the rate limit thresholds — are reasonable as shipped and can be adjusted to taste.
Note that the spam section enables a lookup against the StopForumSpam service on registration. It is an outbound HTTP call with a two second timeout. If your server has no outbound internet access, set stopforumspam_enabled to false or registrations will be slow.
The schema ships with no administrator. The seed account that used to exist was deliberately removed, so there is no default password to worry about and no account for anyone to guess.
Visit the site and register normally, through the ordinary signup form. Complete the email verification if mail is already working, or verify the row by hand if it is not. Then promote yourself directly in the database:
UPDATE users SET role = 'admin' WHERE email = 'you@example.com';The role column is an enum with three values: admin, moderator and user. Admins reach everything under /admin. Moderators reach /moderator, which is a subset covering reports, user actions, content removal, tickets and applications. Admins can also use the moderator panel.
Once you have an admin account, most day-to-day configuration happens in the interface rather than in files.
Mail is required for real use. Registration verification, password resets, two-factor login codes, account deletion confirmation, birthday notifications, the weekly digest and broadcast campaigns all depend on it.
Fill in the mail block of the secrets file. Port 465 with ssl encryption, or port 587 with tls, are the two normal combinations. The from address should be one your SMTP provider actually authorises you to send as, otherwise your mail will be silently dropped or land in spam.
The sending code is in app/Services/SmtpMailer.php and uses the PHPMailer copy bundled in lib/. Outgoing messages are translated into the recipient's preferred language, with about twenty languages currently covered in app/Services/MailTranslations.php.
To test, use the admin mail page at /admin/mail, which sends to an address you type. If nothing arrives, check the PHP error log rather than the site, since mail failures are logged and not surfaced in the interface.
Push is optional. Set ASKNEST_PUSH_ENABLED to 0 and skip this section if you do not want it.
Push requires a VAPID key pair. Generate one with either of these:
npx web-push generate-vapid-keys
php -r 'require "vendor/autoload.php"; print_r(Minishlink\WebPush\VAPID::createVapidKeys());'Put both halves in the secrets file, and set ASKNEST_PUSH_SUBJECT to a mailto URI that reaches you. Push services use that address to contact you if your sending misbehaves, so it should be real.
The public key is passed to the browser automatically once configured. There is no frontend file to edit. The browser side lives in the assets/js/app.js service worker registration, and service-worker.js handles the incoming push event and the notification click.
Two operational notes. Push only works over HTTPS, which in practice you have anyway. And rotating the key pair invalidates every existing subscription, so all your users would silently stop receiving notifications until they re-subscribe. Generate the pair once and keep it.
The default is local storage. ASKNEST_UPLOAD_STORAGE is set to local, images are written under your web root, and there is nothing further to configure. Confirm the permissions from section 8 and you are done.
The alternative is Cloudflare R2, through the small worker included in cloudflare/asknest-media-worker. Use it if you expect real image volume or want uploads off your origin server. The setup has more moving parts, and the pieces have to agree with each other, so follow this order.
Open cloudflare/asknest-media-worker/wrangler.toml. Three values ship as placeholders and all three need your input. Set account_id, which you will find in the Cloudflare dashboard under Workers and Pages, in the right sidebar. Set the route pattern to the hostname you want to serve media from; the zone must already exist in that Cloudflare account. Change the bucket name if you prefer something other than asknest-uploads.
Create the bucket:
wrangler r2 bucket create asknest-uploadsGenerate two secrets, each with openssl rand -hex 32, and push them to the worker:
wrangler secret put MEDIA_TOKEN
wrangler secret put MEDIA_HMAC_SECRETDeploy:
wrangler deployNow go back to your secrets file and set four values. ASKNEST_UPLOAD_STORAGE becomes r2_worker. ASKNEST_R2_WORKER_BASE_URL is the full https URL of the hostname you configured, with no trailing slash. ASKNEST_R2_WORKER_TOKEN must equal the MEDIA_TOKEN you pushed. ASKNEST_R2_WORKER_HMAC_SECRET must equal the MEDIA_HMAC_SECRET you pushed.
The token is a bearer credential and the HMAC secret signs each request along with a timestamp and a content hash, so a mismatch in either one produces rejected uploads rather than a clear error message. If uploads start failing after a change here, that mismatch is the first thing to check.
Read this section carefully. It contains the one step most likely to be missed.
Cron jobs are not managed by editing the crontab directly. They live as rows in the admin_cron_jobs table and are editable from the admin panel at /admin/cron. A script, tools/sync_cron_jobs.php, reads the enabled rows and writes them into the system crontab between two marker comments, leaving anything outside those markers untouched.
Before you install anything, open tools/sync_cron_jobs.php and look at line 15. It defines the self-scheduling line that the script writes back into the crontab, and it ships with placeholder paths:
const SYNC_SELF = '* * * * * /path/to/php /path/to/webroot/tools/sync_cron_jobs.php >> /path/to/webroot/storage/cron_sync.log 2>&1';
Replace both placeholder paths with your real PHP binary and your real project path. If you skip this, the sync will run once, write a broken self-scheduling line into your crontab, and then never run again. That failure is quiet and looks exactly like cron not being set up at all.
With that corrected, install the single bootstrap entry by hand:
* * * * * /usr/bin/php /var/www/asknest/tools/sync_cron_jobs.php >> /var/www/asknest/storage/cron_sync.log 2>&1
Everything else is then managed from the admin panel. The seeded job rows in the database ship with placeholder paths too, for the same privacy reason, so fix them once:
UPDATE admin_cron_jobs
SET command = REPLACE(REPLACE(command,
'/path/to/webroot', '/var/www/asknest'),
'/path/to/php', '/usr/bin/php');The jobs that ship, and what each does:
| Schedule | Job |
|---|---|
| every minute | purge expired diary entries |
| daily at 00:00 | purge expired daily questions |
| daily at 00:01 | generate the day's daily question |
| daily at 00:05 | dispatch birthday notifications |
| Mondays at 08:00 | send the weekly popular digest |
| every 15 minutes | reset and clean up AskNest AI credits |
| hourly | send premium expiry warnings |
| every 15 minutes | process expired premium subscriptions |
| every 5 minutes | publish due scheduled questions |
| every 5 minutes | send pending broadcast mail campaigns |
Each writes to its own log file under storage. Those logs are how you confirm cron is alive; a job that is running successfully appends a line every time it fires, even when it has nothing to do.
The remaining scripts in tools/ are manual utilities rather than cron jobs. verify_indexes.php checks that the performance indexes exist, fix_maintenance.php force-clears maintenance mode if you have locked yourself out of the site, run_sql_migration.php applies a migration file, and debug_daily_question.php helps when daily question generation misbehaves.
With the infrastructure in place, the rest of the configuration is in the interface. Log in as your admin account and work through /admin/settings.
- Site name. It appears throughout the interface and in email.
- Captcha provider. The options are none, Cloudflare Turnstile, or Google reCAPTCHA. Whichever you pick, paste both the site key and the secret key from that provider. Captcha keys live in the database, not the secrets file, so this is done here rather than in a text editor. The content security policy in
.htaccessalready permits both providers' scripts and frames, so no header changes are needed. Leaving captcha at none on a public site will attract automated registrations quickly. - Registration on or off, and moderator applications on or off.
- Maintenance mode takes the site offline for everyone except staff, with a message you supply. If you enable it and then somehow cannot get back in, that is what
tools/fix_maintenance.phpis for. - Announcement banner shows a dismissible message site-wide, with a type and an optional expiry timestamp.
- The AI section is the largest and is covered next.
Beyond the settings page, /admin/system runs a health check covering the PHP version, the required extensions, the database connection, the presence of core tables, whether the encryption key is configured, the uploads directory, session storage and free disk space. Visit it after finishing this guide. It is the fastest way to confirm the installation is sound, and some of its findings offer a one-click fix.
There are three separate AI systems and they are easy to confuse, so here is what each one is.
- User-supplied keys. Individual users add their own OpenAI, DeepSeek or Gemini API key under
/settings/ai, after confirming ownership by email. Those keys are encrypted at rest with your data encryption key. The features they unlock are the text editor assistant, the question assistant, answer drafting, thread summaries and translation. This costs you nothing because each user pays their own provider. It works with no configuration from you. - Site AI moderation. You supply a key at the site level, and the platform uses it to automatically screen submitted content for violations. On the settings page you choose the preferred provider, the model per provider, token limits, a violation threshold, a cooldown, a dispatch delay and an automatic ban duration. Moderation runs as a background task on ordinary web requests, after the response is flushed to the browser where the server supports that, so it does not slow down page loads.
- AskNest AI, a built-in assistant funded by you rather than by the user. You supply the key, you set a daily credit allowance per user, and a cron job resets those credits. Users spend credits instead of supplying a key.
All three are optional and the site runs perfectly with all of them switched off. Provider keys added at the site level are encrypted the same way user keys are.
There is no payment gateway in this application. No Stripe, no PayPal, no card handling of any kind, and therefore no PCI surface to worry about.
The flow is manual. A user goes to /premium, reads and accepts the payment terms, and submits a request. It appears in the admin panel under /admin/premium-requests, where you approve or reject it. Approval activates the membership. Cron jobs then send expiry warnings and process expirations when the term ends.
How the user actually pays you is outside the software. Whatever you arrange, the payment terms page has to describe it accurately, which brings us to the next section.
Premium unlocks saved question folders, profile links, pinned questions, a private diary circle, scheduled questions and extended statistics.
Four templates contain company details that ship as placeholders and must be replaced before you go live. They are legally meaningful pages, not decoration.
app/Views/legal/terms.php terms of use
app/Views/legal/privacy.php privacy policy
app/Views/legal/payments.php payment terms
app/Views/events/security.php a security reporting event page
Search all four for YOUR_COMPANY_NAME and replace it with your legal entity. Search for info@example.com and replace it with a contact address you monitor. Read the surrounding text rather than only swapping the tokens: the privacy policy describes what data is collected and how long it is kept, and the payment terms describe a refund position. Both need to match what you actually do, and in several jurisdictions they need to name a real entity and a real address.
The site name in the interface comes from the admin settings page, not from these files.
The application ships with sensible defaults. This is what is already in place, so that you do not weaken something without realising what it was for.
Passwords are hashed with Argon2id. Sessions use strict mode, are HTTP-only, regenerate their identifier on creation, and set the secure flag automatically when the request is HTTPS, including when that is signalled by a Cloudflare or proxy header. Every state-changing form carries a CSRF token.
The .htaccess file sets a content security policy, nosniff, a referrer policy, frame options and a permissions policy. It denies direct access to dotfiles, SQL files, ini files, logs, markdown files, backup files and the composer manifests. It blocks PHP execution under uploads. The uploads directory has a second .htaccess enforcing the same thing through different mechanisms, because defence in depth is the point.
Rate limiting is applied to authentication and other sensitive endpoints, with a sliding window configured in config/app.php. Registration additionally runs a device fingerprint check and, unless disabled, a StopForumSpam lookup. Optional two-factor authentication by email is available to users.
Error output is disabled at runtime and a global handler catches everything, logging the detail server-side and showing the user a generic error page. Uncaught errors are also written to a database-backed log you can browse at /admin/error-logs. There is a sensitive data redactor that strips credential-like values before anything is logged.
Two things you should do yourself. Serve the whole site over HTTPS and redirect plain HTTP to it. And keep the secrets file at mode 640 or tighter, owned by root, with only the web server's group able to read it.
Work through this list before announcing the site.
- Load the homepage and confirm it renders with styling. If the page appears unstyled, you are almost certainly serving a different document root than the one whose assets you edited, which is the trap described in section 4.
- Request
/config/app.phpin a browser. It must return 403. If it returns PHP source or a blank page, your.htaccessis not being applied,mod_rewriteorAllowOverrideis misconfigured, and the site is not safe to expose. Stop and fix that before anything else. - Register a test account and confirm the verification email arrives.
- Reset a password and confirm that email arrives too.
- Ask a question with an image attached, and confirm the image displays afterwards.
- Visit
/admin/systemand confirm every check reports ok. - Wait two minutes, then look at
storage/cron_sync.log. It should contain a line per minute reporting how many jobs were synced. Then runcrontab -land confirm the managed block is present with your jobs inside it. - If you enabled push, subscribe from a browser and trigger a notification.
- Check
/sitemap.xmland/rss.xmlreturn valid output.
Backups. The admin panel offers a database backup at /admin/database that produces a SQL dump, along with restore and reset actions. Treat these as convenience tools. For real protection, schedule mysqldump outside the application, and back up your secrets file separately, because a database dump without the encryption key leaves the encrypted columns unreadable.
The reset action deletes content while preserving admin and moderator accounts. It requires typing a confirmation phrase. It is genuinely destructive and there is no undo.
Logs. The per-job cron logs under storage grow without bound. Rotate or truncate them periodically. Application errors go to your PHP error log and to the database-backed viewer at /admin/error-logs, which has bulk deletion.
Caches. Trending and leaderboard data are cached as JSON under storage/cache and refresh on their own. Deleting those files is safe; they regenerate on the next request.
Deploying changes. If your PHP runs with OPcache, which it should in production, edited files are not picked up until the cache expires or the process reloads. Reload php-fpm after deploying. When you change a stylesheet or a script, also bump the version query string in app/Views/layouts/main.php, where the asset links carry a ?v= parameter. Browsers and any CDN in front of you will otherwise keep serving the old file, and this is the most common reason a deployed frontend change appears to have done nothing.
- The site loads but every credential behaves as empty. PHP cannot read the secrets file. Check the path, check the file permissions against the user PHP runs as, and check that the secrets directory is listed in
open_basedir. - Sessions do not persist, or database connections fail with a permission error mentioning errno 13. The temporary directory permissions are wrong. Confirm
/tmpis mode 1777 and restart the database and PHP services. - Image uploads fail while everything else works. Either
gdlacks WebP support, or the uploads directory is not writable, or you are on R2 and the token or HMAC secret does not match between the worker and the secrets file. - Cron never runs. Line 15 of
tools/sync_cron_jobs.phpstill contains placeholder paths. See section 14. - A stylesheet or script change has no effect. Wrong document root, or a cached asset version. See sections 4 and 21.
- The site is stuck in maintenance mode and you cannot log in. Run
php tools/fix_maintenance.phpfrom the command line. - Everything returns 500 with no detail. That is by design; error output is suppressed. Read the PHP error log, or
/admin/error-logsif you can still reach it.
A final pass. Every item here has bitten someone.
- Fill in every
CHANGE_MEvalue in the secrets file, and confirm none of the placeholder values survive. - Back up
ASKNEST_DATA_ENCRYPTION_KEYsomewhere separate from the server. - Set
base_urlinconfig/app.phpto your real domain, and confirmenvisproductionanddebugisfalse. - Replace the company name and contact address in the four legal and event templates, and read the text.
- Fix the paths in
tools/sync_cron_jobs.phpand in theadmin_cron_jobsrows. - Fix the paths in both
.user.inifiles if you useopen_basedir. - Configure a captcha provider.
- Confirm
/config/app.phpreturns 403. - Enforce HTTPS.
If you are taking over this installation from someone else, rotate everything before you trust it: the database password, the SMTP password, the VAPID key pair, the R2 worker token and HMAC secret, and any Cloudflare API tokens. The previous operator's credentials should stop working on the day you take over.
AskNest is released under the MIT License. See the LICENSE file for the full text. You are free to use, modify, and distribute it, provided you keep the copyright notice — including the link to the original project at github.com/amazing-things/asknest — in all copies or substantial portions of the software.